rns.moscow 🟥 [git]
map/map_server.py 5fc5be0a3a97bf8e793fe9e92ffffac45a4b6fd2 (5fc5be0a) Text, 75.97 KB
T8b949e#!/opt/reticulum/bin/python3
T8b949e"""
map_server.py - Live LoRa coverage heatmap for wardrive_bot.
Serves a Leaflet heatmap that polls for new points every 5 seconds.
Reads from the same SQLite database written by wardrive_bot.py.
"""
Tff7b72import T7ee787json
Tff7b72import T7ee787os
Tff7b72import T7ee787sqlite3
Tff7b72import T7ee787subprocess
Tff7b72import T7ee787threading
Tff7b72import T7ee787time
Tff7b72import T7ee787urllibT7ee787.T7ee787request
Tff7b72from T7ee787flask Tff7b72import Te6edf3FlaskTb4b4b4, Te6edf3jsonifyTb4b4b4, Te6edf3render_template_stringTb4b4b4, Te6edf3requestTb4b4b4, Te6edf3send_file
Tff7b72from T7ee787werkzeugT7ee787.T7ee787middlewareT7ee787.T7ee787proxy_fix Tff7b72import Te6edf3ProxyFix
Te6edf3CONFIG_PATH Tff7b72= Ta5d6ff"Ta5d6ff/etc/reticulum/wardrive_map.jsonTa5d6ff"
Tff7b72def Td2a8ff_load_configTb4b4b4(Te6edf3pathTb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Tff7b72with Tffa657openTb4b4b4(Te6edf3pathTb4b4b4) Tff7b72as Te6edf3fTb4b4b4:
Tff7b72return Te6edf3jsonTff7b72.Td2a8ffloadTb4b4b4(Te6edf3fTb4b4b4)
Tff7b72except Tf85149FileNotFoundErrorTb4b4b4:
Tff7b72return Tb4b4b4{Tb4b4b4}
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[WARN] Could not load config from Tffd700{Te6edf3pathTffd700}Ta5d6ff: Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4)
Tff7b72return Tb4b4b4{Tb4b4b4}
Te6edf3_cfg Tff7b72= Te6edf3_load_configTb4b4b4(Te6edf3CONFIG_PATHTb4b4b4)
Te6edf3DB_PATH Tff7b72= Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffexpanduserTb4b4b4(Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdb_pathTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff~/wardrive_bot.dbTa5d6ff"Tb4b4b4)Tb4b4b4)
Te6edf3NODE_PRUNE_AGE Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffnode_prune_age_secsTa5d6ff"Tb4b4b4, T79c0ff7 Tff7b72* T79c0ff24 Tff7b72* T79c0ff3600Tb4b4b4) T8b949e# default 7 days
Te6edf3DISCOVERY_REFRESH_SECS Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdiscovery_refresh_secsTa5d6ff"Tb4b4b4, T79c0ff15Tb4b4b4) T8b949e# background rnstatus poll
Te6edf3HOST Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflisten_onTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff0.0.0.0Ta5d6ff"Tb4b4b4)
Te6edf3PORT Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffportTa5d6ff"Tb4b4b4, T79c0ff5000Tb4b4b4)
Te6edf3PAGE_TITLE Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffpage_titleTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffWardrive Coverage MapTa5d6ff"Tb4b4b4)
Te6edf3INITIAL_LAT Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffinitial_latTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4)
Te6edf3INITIAL_LON Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffinitial_lonTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4)
Te6edf3INITIAL_ZOOM Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffinitial_zoomTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4)
Te6edf3DEFAULT_COUNTRY Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdefault_country_filterTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffRussiaTa5d6ff"Tb4b4b4)
Te6edf3PINNED_NODES Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffpinned_nodesTa5d6ff"Tb4b4b4, Tb4b4b4[Tb4b4b4]Tb4b4b4)
Te6edf3GEO_CACHE_PATH Tff7b72= Te6edf3_cfgTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffgeo_cache_pathTa5d6ff"Tb4b4b4,
Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffjoinTb4b4b4(Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffdirnameTb4b4b4(Te6edf3DB_PATHTb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffwardrive_geo_cache.jsonTa5d6ff"Tb4b4b4)Tb4b4b4)
Tff7b72def Td2a8ff_load_geo_cacheTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Tff7b72with Tffa657openTb4b4b4(Te6edf3GEO_CACHE_PATHTb4b4b4) Tff7b72as Te6edf3fTb4b4b4:
Tff7b72return Te6edf3jsonTff7b72.Td2a8ffloadTb4b4b4(Te6edf3fTb4b4b4)
Tff7b72except Tf85149ExceptionTb4b4b4:
Tff7b72return Tb4b4b4{Tb4b4b4}
Tff7b72def Td2a8ff_save_geo_cacheTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3existed Tff7b72= Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffexistsTb4b4b4(Te6edf3GEO_CACHE_PATHTb4b4b4)
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Te6edf3data Tff7b72= Tffa657dictTb4b4b4(Te6edf3_geo_cacheTb4b4b4)
Tff7b72with Tffa657openTb4b4b4(Te6edf3GEO_CACHE_PATHTb4b4b4, Ta5d6ff"Ta5d6ffwTa5d6ff"Tb4b4b4) Tff7b72as Te6edf3fTb4b4b4:
Te6edf3jsonTff7b72.Td2a8ffdumpTb4b4b4(Te6edf3dataTb4b4b4, Te6edf3fTb4b4b4)
Te6edf3action Tff7b72= Ta5d6ff"Ta5d6ffupdatedTa5d6ff" Tff7b72if Te6edf3existed Tff7b72else Ta5d6ff"Ta5d6ffcreatedTa5d6ff"
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[GEO] cache Tffd700{Te6edf3actionTffd700}Ta5d6ff: Tffd700{Te6edf3GEO_CACHE_PATHTffd700}Ta5d6ff (Tffd700{Tffa657lenTb4b4b4(Te6edf3dataTb4b4b4)Tffd700}Ta5d6ff entries)Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[GEO] cache save failed: Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Te6edf3_geo_cache Tff7b72= Te6edf3_load_geo_cacheTb4b4b4(Tb4b4b4)
Te6edf3_geo_lock Tff7b72= Te6edf3threadingTff7b72.Td2a8ffLockTb4b4b4(Tb4b4b4)
Te6edf3_geo_busy Tff7b72= Tb4b4b4[Tff7b72FalseTb4b4b4]
Tff7b72def Td2a8ff_geo_keyTb4b4b4(Te6edf3latTb4b4b4, Te6edf3lonTb4b4b4)Tb4b4b4:
Tff7b72return Ta5d6fffTa5d6ff"Tffd700{Tffa657roundTb4b4b4(Te6edf3latTb4b4b4, T79c0ff2Tb4b4b4)Tffd700:Ta5d6ff.2fTffd700}Ta5d6ff,Tffd700{Tffa657roundTb4b4b4(Te6edf3lonTb4b4b4, T79c0ff2Tb4b4b4)Tffd700:Ta5d6ff.2fTffd700}Ta5d6ff"
Tff7b72def Td2a8ff_fetch_geoTb4b4b4(Te6edf3latTb4b4b4, Te6edf3lonTb4b4b4)Tb4b4b4:
Te6edf3key Tff7b72= Te6edf3_geo_keyTb4b4b4(Te6edf3latTb4b4b4, Te6edf3lonTb4b4b4)
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Tff7b72if Te6edf3key Tff7b72in Te6edf3_geo_cacheTb4b4b4:
Tff7b72return
Tff7b72tryTb4b4b4:
Te6edf3url Tff7b72= Tb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ffhttps://api.bigdatacloud.net/data/reverse-geocode-clientTa5d6ff"
Ta5d6fffTa5d6ff"Ta5d6ff?latitude=Tffd700{Te6edf3latTffd700}Ta5d6ff&longitude=Tffd700{Te6edf3lonTffd700}Ta5d6ff&localityLanguage=enTa5d6ff"Tb4b4b4)
Tff7b72with Te6edf3urllibTff7b72.Td2a8ffrequestTff7b72.Td2a8ffurlopenTb4b4b4(Te6edf3urlTb4b4b4, Te6edf3timeoutTff7b72=T79c0ff8Tb4b4b4) Tff7b72as Te6edf3rTb4b4b4:
Te6edf3d Tff7b72= Te6edf3jsonTff7b72.Td2a8ffloadsTb4b4b4(Te6edf3rTff7b72.Td2a8ffreadTb4b4b4(Tb4b4b4)Tb4b4b4)
Te6edf3_COUNTRY_SHORT Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffUnited States of AmericaTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffUnited StatesTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffUnited Kingdom of Great Britain and Northern IrelandTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffUnited KingdomTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffRussian FederationTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffRussiaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffRepublic of KoreaTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffSouth KoreaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffDemocratic PeopleTa5d6ff'Ta5d6ffs Republic of KoreaTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffNorth KoreaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffIran (Islamic Republic of)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffIranTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffSyrian Arab RepublicTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffSyriaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffViet NamTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffVietnamTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffBolivia (Plurinational State of)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffBoliviaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffVenezuela (Bolivarian Republic of)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffVenezuelaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffMoldova (the Republic of)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffMoldovaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffTanzania (the United Republic of)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTanzaniaTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffNetherlands (Kingdom of the)Ta5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffNetherlandsTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffChinaTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffChinaTa5d6ff"Tb4b4b4,
Tb4b4b4}
Te6edf3country Tff7b72= Tb4b4b4(Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffcountryNameTa5d6ff"Tb4b4b4) Tff7b72or Ta5d6ff"Ta5d6ff"Tb4b4b4)Tff7b72.Td2a8ffreplaceTb4b4b4(Ta5d6ff"Ta5d6ff (the)Ta5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tff7b72.Td2a8ffreplaceTb4b4b4(Ta5d6ff"Ta5d6ff(the) Ta5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)
Te6edf3country Tff7b72= Te6edf3_COUNTRY_SHORTTff7b72.Td2a8ffgetTb4b4b4(Te6edf3countryTb4b4b4, Te6edf3countryTb4b4b4)
Te6edf3geo Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffcountryTa5d6ff"Tb4b4b4: Te6edf3countryTb4b4b4,
Ta5d6ff"Ta5d6ffstateTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffprincipalSubdivisionTa5d6ff"Tb4b4b4) Tff7b72or Ta5d6ff"Ta5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffcityTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffcityTa5d6ff"Tb4b4b4) Tff7b72or Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflocalityTa5d6ff"Tb4b4b4) Tff7b72or Ta5d6ff"Ta5d6ff"Tb4b4b4,
Tb4b4b4}
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Te6edf3_geo_cacheTb4b4b4[Te6edf3keyTb4b4b4] Tff7b72= Te6edf3geo
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[GEO] Tffd700{Te6edf3latTffd700}Ta5d6ff,Tffd700{Te6edf3lonTffd700}Ta5d6ff: Tffd700{Tffa657typeTb4b4b4(Te6edf3eTb4b4b4)Tff7b72.Td2a8ff__name__Tffd700}Ta5d6ff: Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Tff7b72def Td2a8ff_geocode_bgTb4b4b4(Te6edf3nodesTb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3seen Tff7b72= Tffa657setTb4b4b4(Tb4b4b4)
Tff7b72for Te6edf3n Tff7b72in Te6edf3nodesTb4b4b4:
Te6edf3key Tff7b72= Te6edf3_geo_keyTb4b4b4(Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4]Tb4b4b4, Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4]Tb4b4b4)
Tff7b72if Te6edf3key Tff7b72in Te6edf3seenTb4b4b4:
Tff7b72continue
Te6edf3seenTff7b72.Td2a8ffaddTb4b4b4(Te6edf3keyTb4b4b4)
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Tff7b72if Te6edf3key Tff7b72in Te6edf3_geo_cacheTb4b4b4:
Tff7b72continue
Te6edf3_fetch_geoTb4b4b4(Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4]Tb4b4b4, Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4]Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[GEO] thread error: Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Tff7b72finallyTb4b4b4:
Te6edf3_geo_busyTb4b4b4[T79c0ff0Tb4b4b4] Tff7b72= Tff7b72False
Te6edf3_save_geo_cacheTb4b4b4(Tb4b4b4)
Tff7b72def Td2a8ff_enrich_geoTb4b4b4(Te6edf3nodesTb4b4b4)Tb4b4b4:
Te6edf3uncached Tff7b72= Tb4b4b4[Tb4b4b4]
Tff7b72for Te6edf3n Tff7b72in Te6edf3nodesTb4b4b4:
Te6edf3key Tff7b72= Te6edf3_geo_keyTb4b4b4(Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4]Tb4b4b4, Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4]Tb4b4b4)
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Te6edf3geo Tff7b72= Te6edf3_geo_cacheTff7b72.Td2a8ffgetTb4b4b4(Te6edf3keyTb4b4b4)
Tff7b72if Te6edf3geoTb4b4b4:
Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffcountryTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3geoTb4b4b4[Ta5d6ff"Ta5d6ffcountryTa5d6ff"Tb4b4b4]
Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffstateTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3geoTb4b4b4[Ta5d6ff"Ta5d6ffstateTa5d6ff"Tb4b4b4]
Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffcityTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3geoTb4b4b4[Ta5d6ff"Ta5d6ffcityTa5d6ff"Tb4b4b4]
Tff7b72elseTb4b4b4:
Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffcountryTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffstateTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3nTb4b4b4[Ta5d6ff"Ta5d6ffcityTa5d6ff"Tb4b4b4] Tff7b72= Ta5d6ff"Ta5d6ff"
Te6edf3uncachedTff7b72.Td2a8ffappendTb4b4b4(Te6edf3nTb4b4b4)
Tff7b72if Te6edf3uncached Tff7b72and Tff7b72not Te6edf3_geo_busyTb4b4b4[T79c0ff0Tb4b4b4]Tb4b4b4:
Te6edf3_geo_busyTb4b4b4[T79c0ff0Tb4b4b4] Tff7b72= Tff7b72True
Te6edf3threadingTff7b72.Td2a8ffThreadTb4b4b4(Te6edf3targetTff7b72=Te6edf3_geocode_bgTb4b4b4, Te6edf3argsTff7b72=Tb4b4b4(Te6edf3uncachedTb4b4b4,Tb4b4b4)Tb4b4b4, Te6edf3daemonTff7b72=Tff7b72TrueTb4b4b4)Tff7b72.Td2a8ffstartTb4b4b4(Tb4b4b4)
Te6edf3app Tff7b72= Te6edf3FlaskTb4b4b4(Tff7b72__name__Tb4b4b4)
Te6edf3appTff7b72.Td2a8ffwsgi_app Tff7b72= Te6edf3ProxyFixTb4b4b4(Te6edf3appTff7b72.Td2a8ffwsgi_appTb4b4b4, Te6edf3x_forTff7b72=T79c0ff1Tb4b4b4, Te6edf3x_protoTff7b72=T79c0ff1Tb4b4b4, Te6edf3x_hostTff7b72=T79c0ff1Tb4b4b4)
Te6edf3MAP_HTML Tff7b72= Ta5d6ff"""Ta5d6ff<!DOCTYPE html>
Ta5d6ff<html>
Ta5d6ff<head>
Ta5d6ff <meta charset=Ta5d6ff"Ta5d6ffutf-8Ta5d6ff"Ta5d6ff>
Ta5d6ff <title>Ta5d6ff{{Ta5d6ff page_title }}</title>
Ta5d6ff <meta name=Ta5d6ff"Ta5d6ffviewportTa5d6ff"Ta5d6ff content=Ta5d6ff"Ta5d6ffwidth=device-width, initial-scale=1Ta5d6ff"Ta5d6ff>
Ta5d6ff <link rel=Ta5d6ff"Ta5d6ffstylesheetTa5d6ff"Ta5d6ff href=Ta5d6ff"Ta5d6ffhttps://unpkg.com/leaflet@1.9.4/dist/leaflet.cssTa5d6ff"Ta5d6ff>
Ta5d6ff <style>
Ta5d6ff :root Ta5d6ff{
Ta5d6ff --bg: #111;
Ta5d6ff --bg2: #1a1a1a;
Ta5d6ff --bg3: #2a2a2a;
Ta5d6ff --bg-entry: #1e1e1e;
Ta5d6ff --border: #333;
Ta5d6ff --border2: #444;
Ta5d6ff --border3: #666;
Ta5d6ff --text: #ccc;
Ta5d6ff --text-dim: #888;
Ta5d6ff --text-muted: #555;
Ta5d6ff --text-faint: #444;
Ta5d6ff --text-hash: #777;
Ta5d6ff --mask-bg: #111;
Ta5d6ff --tip-bg: #222;
Ta5d6ff color-scheme: dark;
Ta5d6ff }
Ta5d6ff body.light-theme Ta5d6ff{
Ta5d6ff --bg: #fff;
Ta5d6ff --bg2: #f4f4f4;
Ta5d6ff --bg3: #e8e8e8;
Ta5d6ff --bg-entry: #eaeaea;
Ta5d6ff --border: #ddd;
Ta5d6ff --border2: #bbb;
Ta5d6ff --border3: #999;
Ta5d6ff --text: #222;
Ta5d6ff --text-dim: #555;
Ta5d6ff --text-muted: #888;
Ta5d6ff --text-faint: #aaa;
Ta5d6ff --text-hash: #999;
Ta5d6ff --mask-bg: #fff;
Ta5d6ff --tip-bg: #f5f5f5;
Ta5d6ff color-scheme: light;
Ta5d6ff }
Ta5d6ff * Ta5d6ff{Ta5d6ff box-sizing: border-box; }
Ta5d6ff html Ta5d6ff{Ta5d6ff height: 100Ta5d6ff%Ta5d6ff; }
Ta5d6ff body Ta5d6ff{Ta5d6ff height: 100Ta5d6ff%Ta5d6ff; margin: 0; padding: 0; display: flex; flex-direction: row; background: var(--bg2); }
Ta5d6ff #map Ta5d6ff{Ta5d6ff flex: 1; min-width: 0; }
Ta5d6ff .leaflet-bar a Ta5d6ff{
Ta5d6ff background-color: var(--bg2) !important; color: var(--text) !important; border-color: var(--border2) !important;
Ta5d6ff }
Ta5d6ff .leaflet-bar a:hover Ta5d6ff{
Ta5d6ff background-color: var(--bg3) !important; color: var(--text) !important;
Ta5d6ff }
Ta5d6ff .leaflet-bar a.leaflet-disabled Ta5d6ff{
Ta5d6ff background-color: var(--bg) !important; color: var(--text-faint) !important;
Ta5d6ff }
Ta5d6ff .leaflet-control-attribution Ta5d6ff{Ta5d6ff display: none; }
Ta5d6ff .follow-btn Ta5d6ff{Ta5d6ff font-size: 15px !important; line-height: 26px !important; }
Ta5d6ff .follow-btn.active Ta5d6ff{Ta5d6ff background-color: #1a65d4 !important; color: #fff !important; }
Ta5d6ff .follow-btn.active:hover Ta5d6ff{Ta5d6ff background-color: #2e7fe8 !important; color: #fff !important; }
Ta5d6ff body.light-theme .follow-btn.active Ta5d6ff{Ta5d6ff background-color: #1a65d4 !important; color: #fff !important; }
Ta5d6ff body.light-theme .follow-btn.active:hover Ta5d6ff{Ta5d6ff background-color: #2e7fe8 !important; color: #fff !important; }
Ta5d6ff #panel Ta5d6ff{
Ta5d6ff width: 326px; display: none; flex-direction: column;
Ta5d6ff background: var(--bg); color: var(--text); font: 13px/1.4 monospace;
Ta5d6ff border-left: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff position: relative; z-index: 500;
Ta5d6ff }
Ta5d6ff #panel-header Ta5d6ff{
Ta5d6ff padding: 10px 12px; font-size: 11px; font-weight: bold;
Ta5d6ff letter-spacing: 0.08em; text-transform: uppercase;
Ta5d6ff color: var(--text-dim); border-bottom: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff display: flex; align-items: center; justify-content: space-between;
Ta5d6ff }
Ta5d6ff .panel-btn Ta5d6ff{
Ta5d6ff background: var(--bg2); border: 1px solid var(--border2); color: var(--text-dim);
Ta5d6ff cursor: pointer; font-size: 12px; line-height: 1;
Ta5d6ff padding: 2px 6px; border-radius: 3px;
Ta5d6ff }
Ta5d6ff .panel-btn:hover Ta5d6ff{Ta5d6ff background: var(--bg3); color: var(--text); }
Ta5d6ff #panel-open-wrap Ta5d6ff{
Ta5d6ff position: fixed; right: 0; top: 50Ta5d6ff%Ta5d6ff; transform: translateY(-50Ta5d6ff%Ta5d6ff);
Ta5d6ff z-index: 600; cursor: pointer;
Ta5d6ff background: var(--border2);
Ta5d6ff clip-path: polygon(0 8px, 100Ta5d6ff%Ta5d6ff 0, 100Tffd700% 100%Ta5d6ff, 0 calc(100Ta5d6ff%Ta5d6ff - 8px));
Ta5d6ff }
Ta5d6ff #panel-open Ta5d6ff{
Ta5d6ff display: block; writing-mode: vertical-rl;
Ta5d6ff background: var(--bg); border: none;
Ta5d6ff color: var(--text-dim); cursor: pointer; font: 11px monospace;
Ta5d6ff padding: 12px 4px;
Ta5d6ff clip-path: polygon(1px 9px, 100Ta5d6ff%Ta5d6ff 1px, 100Tffd700% cTa5d6ffalc(100Ta5d6ff%Ta5d6ff - 1px), 1px calc(100Ta5d6ff%Ta5d6ff - 9px));
Ta5d6ff }
Ta5d6ff #panel-open-wrap:hover #panel-open Ta5d6ff{Ta5d6ff background: var(--bg2); color: var(--text); }
Ta5d6ff .entry-hash Ta5d6ff{
Ta5d6ff color: var(--text-faint); cursor: pointer;
Ta5d6ff }
Ta5d6ff .entry-hash:hover Ta5d6ff{Ta5d6ff color: var(--text-hash); }
Ta5d6ff #filter-bar Ta5d6ff{
Ta5d6ff padding: 8px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff display: flex; flex-direction: column; gap: 6px;
Ta5d6ff }
Ta5d6ff #filter-dates Ta5d6ff{Ta5d6ff display: flex; align-items: center; gap: 6px; }
Ta5d6ff .date-sep Ta5d6ff{Ta5d6ff color: var(--text-faint); }
Ta5d6ff #filter-btns Ta5d6ff{Ta5d6ff display: flex; gap: 4px; }
Ta5d6ff #filter-btns .panel-btn Ta5d6ff{Ta5d6ff flex: 1; }
Ta5d6ff #filter-apply Ta5d6ff{Ta5d6ff background: #1a3a1a; border-color: #3a7a3a; color: #7c7; }
Ta5d6ff #filter-apply:hover Ta5d6ff{Ta5d6ff background: #1f4a1f; color: #9e9; }
Ta5d6ff #filter-clear Ta5d6ff{Ta5d6ff background: #3a1a1a; border-color: #7a3a3a; color: #c77; }
Ta5d6ff #filter-clear:hover Ta5d6ff{Ta5d6ff background: #4a1f1f; color: #e99; }
Ta5d6ffbody.light-theme #filter-apply Ta5d6ff{Ta5d6ff background: #e8f5e8; border-color: #4a8a4a; color: #2a6a2a; }
Ta5d6ff body.light-theme #filter-apply:hover Ta5d6ff{Ta5d6ff background: #d5ecd5; color: #1a5a1a; }
Ta5d6ff body.light-theme #filter-clear Ta5d6ff{Ta5d6ff background: #f5e8e8; border-color: #8a4a4a; color: #6a2a2a; }
Ta5d6ff body.light-theme #filter-clear:hover Ta5d6ff{Ta5d6ff background: #ecd5d5; color: #5a1a1a; }
Ta5d6ff .date-input Ta5d6ff{
Ta5d6ff background: var(--bg2); border: 1px solid var(--border2); color: var(--text-dim);
Ta5d6ff font: 11px monospace; padding: 2px 4px; border-radius: 3px; flex: 1;
Ta5d6ff }
Ta5d6ff .date-input:focus Ta5d6ff{Ta5d6ff outline: none; border-color: var(--border3); color: var(--text); }
Ta5d6ff #panel-list Ta5d6ff{
Ta5d6ff flex: 1; overflow-y: auto; padding: 6px 0;
Ta5d6ff }
Ta5d6ff .entry Ta5d6ff{
Ta5d6ff padding: 7px 12px; border-bottom: 1px solid var(--bg-entry);
Ta5d6ff font-size: 12px; line-height: 1.6;
Ta5d6ff }
Ta5d6ff .entry:hover Ta5d6ff{Ta5d6ff background: var(--bg2); }
Ta5d6ff .entry-time Ta5d6ff{Ta5d6ff color: var(--text-muted); font-size: 11px; display: flex; align-items: center; gap: 4px; }
Ta5d6ff .entry-from Ta5d6ff{Ta5d6ff color: var(--text-faint); }
Ta5d6ff .ts Ta5d6ff{Ta5d6ff position: relative; cursor: default; }
Ta5d6ff .leaflet-container Ta5d6ff{Ta5d6ff cursor: default !important; }
Ta5d6ff .leaflet-dragging .leaflet-container Ta5d6ff{Ta5d6ff cursor: grabbing !important; }
Ta5d6ff .ts:hover Ta5d6ff{Ta5d6ff color: var(--text-dim); }
Ta5d6ff .ts:hover::after Ta5d6ff{
Ta5d6ff content: attr(data-date);
Ta5d6ff position: absolute; bottom: calc(100Ta5d6ff%Ta5d6ff + 4px); left: 0;
Ta5d6ff background: var(--tip-bg); border: 1px solid var(--border2); color: var(--text);
Ta5d6ff padding: 3px 8px; border-radius: 4px; white-space: nowrap;
Ta5d6ff font-size: 11px; font-family: monospace; pointer-events: none; z-index: 600;
Ta5d6ff }
Ta5d6ff .entry-coords Ta5d6ff{Ta5d6ff color: var(--text-dim); font-size: 11px; cursor: pointer; }
Ta5d6ff .src-pill Ta5d6ff{
Ta5d6ff font-size: 8px; font-family: monospace;
Ta5d6ff padding: 1px 3px; border-radius: 3px; white-space: nowrap;
Ta5d6ff }
Ta5d6ff .src-ann Ta5d6ff{Ta5d6ff background: #1a1a2e; color: #55a; border: 1px solid #33c; }
Ta5d6ff .src-pkt Ta5d6ff{Ta5d6ff background: #1a2e1a; color: #585; border: 1px solid #3a3; }
Ta5d6ff .src-relay Ta5d6ff{Ta5d6ff background: #2e1a2e; color: #a5a; border: 1px solid #a3a; }
Ta5d6ff .entry-coords:hover Ta5d6ff{Ta5d6ff color: var(--text); }
Ta5d6ff .rf-label Ta5d6ff{Ta5d6ff font-size: 9px; color: var(--text-muted); margin-right: 3px; vertical-align: middle; }
Ta5d6ff .rf-bar Ta5d6ff{
Ta5d6ff display: inline-block; width: 58px; height: 13px;
Ta5d6ff background: linear-gradient(to right, #c33, #cc3, #3a3);
Ta5d6ff position: relative; vertical-align: middle;
Ta5d6ff border: 1px solid #888;
Ta5d6ff }
Ta5d6ff .rf-bar-q Ta5d6ff{
Ta5d6ff background: linear-gradient(to right, #1144aa, #11aadd);
Ta5d6ff }
Ta5d6ff .rf-mask Ta5d6ff{
Ta5d6ff position: absolute; right: 0; top: 0; bottom: 0; background: var(--mask-bg);
Ta5d6ff }
Ta5d6ff .rf-text Ta5d6ff{
Ta5d6ff position: absolute; inset: 0; display: flex; align-items: center;
Ta5d6ff justify-content: center; font-size: 10px; color: #fff;
Ta5d6ff text-shadow: 0 0 3px #000; font-weight: bold; z-index: 1;
Ta5d6ff }
Ta5d6ff #hover-tip Ta5d6ff{
Ta5d6ff position: fixed; pointer-events: none; z-index: 700; display: none;
Ta5d6ff background: var(--bg); border: 1px solid var(--border2); border-radius: 3px;
Ta5d6ff padding: 4px 6px;
Ta5d6ff }
Ta5d6ff .hover-row Ta5d6ff{Ta5d6ff display: flex; gap: 4px; align-items: center; }
Ta5d6ff .hover-row > .vbar-col Ta5d6ff{Ta5d6ff font-size: 7px; font-family: Ta5d6ff'Ta5d6ffCourier NewTa5d6ff'Ta5d6ff, monospace; white-space: nowrap; position: relative; top: 2px; }
Ta5d6ff #hover-bars-dl Ta5d6ff{Ta5d6ff margin-top: 3px; padding-top: 3px; border-top: 1px solid var(--border2); }
Ta5d6ff .vbar-wrap Ta5d6ff{
Ta5d6ff width: 4px; height: 18px; position: relative;
Ta5d6ff background: linear-gradient(to top, #c33, #cc3, #3a3);
Ta5d6ff }
Ta5d6ff .vbar-mask Ta5d6ff{Ta5d6ff position: absolute; top: 0; width: 100Ta5d6ff%Ta5d6ff; background: var(--mask-bg); }
Ta5d6ff .vbar-col Ta5d6ff{Ta5d6ff display: flex; flex-direction: column; align-items: center; gap: 1px; }
Ta5d6ff .vbar-lbl Ta5d6ff{Ta5d6ff font-size: 7px; color: var(--text-muted); font-family: Ta5d6ff'Ta5d6ffCourier NewTa5d6ff'Ta5d6ff, monospace; position: relative; top: -2px; }
Ta5d6ff #status Ta5d6ff{
Ta5d6ff padding: 8px 12px; font-size: 11px; color: var(--text-muted);
Ta5d6ff border-top: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff display: flex; justify-content: space-between;
Ta5d6ff }
Ta5d6ff .map-popup .leaflet-popup-content-wrapper,
Ta5d6ff .map-popup .leaflet-popup-tip Ta5d6ff{Ta5d6ff background: var(--bg); color: var(--text); border: 1px solid var(--border2); }
Ta5d6ff .map-popup .leaflet-popup-content-wrapper Ta5d6ff{Ta5d6ff overflow: hidden; }
Ta5d6ff .map-popup .leaflet-popup-content Ta5d6ff{Ta5d6ff margin: 0; }
Ta5d6ff #nodes-open-wrap Ta5d6ff{
Ta5d6ff position: fixed; right: 0; top: 25Ta5d6ff%Ta5d6ff; transform: translateY(-50Ta5d6ff%Ta5d6ff);
Ta5d6ff z-index: 595; cursor: pointer;
Ta5d6ff background: var(--border2);
Ta5d6ff clip-path: polygon(0 8px, 100Ta5d6ff%Ta5d6ff 0, 100Tffd700% 100%Ta5d6ff, 0 calc(100Ta5d6ff%Ta5d6ff - 8px));
Ta5d6ff }
Ta5d6ff #nodes-open Ta5d6ff{
Ta5d6ff display: block; writing-mode: vertical-rl;
Ta5d6ff background: var(--bg); border: none;
Ta5d6ff color: var(--text-dim); cursor: pointer; font: 11px monospace;
Ta5d6ff padding: 12px 4px;
Ta5d6ff clip-path: polygon(1px 9px, 100Ta5d6ff%Ta5d6ff 1px, 100Tffd700% cTa5d6ffalc(100Ta5d6ff%Ta5d6ff - 1px), 1px calc(100Ta5d6ff%Ta5d6ff - 9px));
Ta5d6ff }
Ta5d6ff #nodes-open-wrap:hover #nodes-open Ta5d6ff{Ta5d6ff background: var(--bg2); color: var(--text); }
Ta5d6ff #nodes-panel Ta5d6ff{
Ta5d6ff display: none; position: fixed; right: 0; top: 0; bottom: 0;
Ta5d6ff width: 260px; z-index: 610; flex-direction: column;
Ta5d6ff background: var(--bg); color: var(--text); font: 13px/1.4 monospace;
Ta5d6ff border-left: 1px solid var(--border);
Ta5d6ff }
Ta5d6ff #nodes-search-bar Ta5d6ff{
Ta5d6ff padding: 8px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff }
Ta5d6ff #nodes-search Ta5d6ff{
Ta5d6ff width: 100Ta5d6ff%Ta5d6ff; background: var(--bg2); border: 1px solid var(--border2);
Ta5d6ff color: var(--text); font: 11px monospace; padding: 4px 6px;
Ta5d6ff border-radius: 3px; outline: none;
Ta5d6ff }
Ta5d6ff #nodes-search:focus Ta5d6ff{Ta5d6ff border-color: var(--border3); }
Ta5d6ff #nodes-search::placeholder Ta5d6ff{Ta5d6ff color: var(--text-muted); }
Ta5d6ff #nodes-header Ta5d6ff{
Ta5d6ff padding: 10px 12px; font-size: 11px; font-weight: bold;
Ta5d6ff letter-spacing: 0.08em; text-transform: uppercase;
Ta5d6ff color: var(--text-dim); border-bottom: 1px solid var(--border);
Ta5d6ff flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
Ta5d6ff }
Ta5d6ff #nodes-list Ta5d6ff{Ta5d6ff flex: 1; overflow-y: auto; padding: 4px 0; }
Ta5d6ff #nodes-geo-bar Ta5d6ff{
Ta5d6ff padding: 6px 10px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0;
Ta5d6ff }
Ta5d6ff .geo-select Ta5d6ff{
Ta5d6ff width: 100Ta5d6ff%Ta5d6ff; background: var(--bg2); border: 1px solid var(--border2);
Ta5d6ff color: var(--text-dim); font: 11px monospace; padding: 3px 5px;
Ta5d6ff border-radius: 3px; cursor: pointer; margin-bottom: 4px;
Ta5d6ff }
Ta5d6ff .geo-select:last-child Ta5d6ff{Ta5d6ff margin-bottom: 0; }
Ta5d6ff .geo-select:focus Ta5d6ff{Ta5d6ff outline: none; border-color: var(--border3); color: var(--text); }
Ta5d6ff .node-entry Ta5d6ff{
Ta5d6ff padding: 7px 12px; border-bottom: 1px solid var(--bg-entry); cursor: pointer;
Ta5d6ff }
Ta5d6ff .node-entry:hover Ta5d6ff{Ta5d6ff background: var(--bg2); }
Ta5d6ff .node-entry-name Ta5d6ff{Ta5d6ff font-size: 12px; display: flex; align-items: center; gap: 6px; justify-content: space-between; }
Ta5d6ff .node-entry-name-left Ta5d6ff{Ta5d6ff display: flex; align-items: center; gap: 6px; }
Ta5d6ff .node-entry-type Ta5d6ff{Ta5d6ff font-size: 10px; color: var(--text-muted); }
Ta5d6ff .node-entry-meta Ta5d6ff{Ta5d6ff font-size: 10px; color: var(--text-dim); margin-top: 2px; }
Ta5d6ff .node-entry-bottom Ta5d6ff{Ta5d6ff display: flex; justify-content: space-between; align-items: flex-end; margin-top: 1px; }
Ta5d6ff .node-entry-coords Ta5d6ff{Ta5d6ff font-size: 10px; color: var(--text-faint); }
Ta5d6ff .node-entry-age Ta5d6ff{Ta5d6ff font-size: 10px; color: var(--text-faint); }
Ta5d6ff #discovery-container Ta5d6ff{
Ta5d6ff position: fixed; left: 0; top: 50Ta5d6ff%Ta5d6ff; transform: translateY(-50Ta5d6ff%Ta5d6ff);
Ta5d6ff z-index: 599;
Ta5d6ff }
Ta5d6ff #discovery-open-wrap Ta5d6ff{
Ta5d6ff cursor: pointer;
Ta5d6ff background: var(--border2);
Ta5d6ff clip-path: polygon(0 0, 100Ta5d6ff%Ta5d6ff 8px, 100Tffd700% cTa5d6ffalc(100Ta5d6ff%Ta5d6ff - 8px), 0 100Ta5d6ff%Ta5d6ff);
Ta5d6ff }
Ta5d6ff #discovery-open Ta5d6ff{
Ta5d6ff display: block; writing-mode: vertical-rl;
Ta5d6ff background: var(--bg); border: none;
Ta5d6ff color: var(--text-dim); cursor: pointer; font: 11px monospace;
Ta5d6ff padding: 12px 4px;
Ta5d6ff clip-path: polygon(0 1px, calc(100Ta5d6ff%Ta5d6ff - 1px) 9px, calc(100Ta5d6ff%Ta5d6ff - 1px) calc(100Ta5d6ff%Ta5d6ff - 9px), 0 calc(100Ta5d6ff%Ta5d6ff - 1px));
Ta5d6ff }
Ta5d6ff #discovery-open-wrap:hover #discovery-open Ta5d6ff{Ta5d6ff background: var(--bg2); color: var(--text); }
Ta5d6ff .tab-dot Ta5d6ff{Ta5d6ff display: none; width: 6px; height: 6px; border-radius: 50Ta5d6ff%Ta5d6ff; margin-top: 4px; }
Ta5d6ff #discovery-options Ta5d6ff{
Ta5d6ff padding: 6px 10px 8px; border-top: 1px solid var(--bg3);
Ta5d6ff }
Ta5d6ff .node-label Ta5d6ff{
Ta5d6ff background: rgba(0,0,0,0.72); border: 1px solid #555; border-radius: 2px;
Ta5d6ff font: bold 7px/1 system-ui, sans-serif; padding: 1px 3px;
Ta5d6ff white-space: nowrap; box-shadow: none;
Ta5d6ff }
Ta5d6ff .node-label::before Ta5d6ff{Ta5d6ff display: none; }
Ta5d6ff .node-label-rnode Ta5d6ff{Ta5d6ff color: #f80; }
Ta5d6ff .node-label-backbone Ta5d6ff{Ta5d6ff color: #7bf; }
Ta5d6ff .node-label-i2p Ta5d6ff{Ta5d6ff color: #ad4; }
Ta5d6ff .node-label-halow Ta5d6ff{Ta5d6ff color: #0cf; }
Ta5d6ff .node-stack Ta5d6ff{Ta5d6ff cursor: pointer; }
Ta5d6ff #discovery-panel Ta5d6ff{
Ta5d6ff display: none; width: 142px; flex-direction: column;
Ta5d6ff background: var(--bg); color: var(--text); font: 12px/1.4 monospace;
Ta5d6ff border: 1px solid var(--border); border-left: none; border-radius: 0 3px 3px 0;
Ta5d6ff }
Ta5d6ff #discovery-header Ta5d6ff{
Ta5d6ff padding: 10px 12px; font-size: 11px; font-weight: bold;
Ta5d6ff letter-spacing: 0.08em; text-transform: uppercase;
Ta5d6ff color: var(--text-dim); border-bottom: 1px solid var(--border);
Ta5d6ff display: flex; align-items: center; justify-content: space-between;
Ta5d6ff }
Ta5d6ff #discovery-list Ta5d6ff{Ta5d6ff padding: 8px 10px; display: flex; flex-direction: column; gap: 8px; }
Ta5d6ff .disc-item Ta5d6ff{Ta5d6ff display: flex; align-items: center; gap: 7px; cursor: pointer; user-select: none; }
Ta5d6ff .disc-item input[type=checkbox] Ta5d6ff{Ta5d6ff margin: 0; width: 13px; height: 13px; cursor: pointer; flex-shrink: 0; }
Ta5d6ff .disc-dot Ta5d6ff{Ta5d6ff width: 8px; height: 8px; border-radius: 50Ta5d6ff%Ta5d6ff; flex-shrink: 0; }
Ta5d6ff .disc-label Ta5d6ff{Ta5d6ff font-size: 11px; color: var(--text-dim); }
Ta5d6ff #check-rnode Ta5d6ff{Ta5d6ff accent-color: #f80; }
Ta5d6ff #check-backbone Ta5d6ff{Ta5d6ff accent-color: #48f; }
Ta5d6ff #check-i2p Ta5d6ff{Ta5d6ff accent-color: #ad4; }
Ta5d6ff #check-halow Ta5d6ff{Ta5d6ff accent-color: #0cf; }
Ta5d6ff #map-controls Ta5d6ff{
Ta5d6ff position: fixed; bottom: 16px; left: 16px; z-index: 600;
Ta5d6ff display: flex; gap: 6px; align-items: center;
Ta5d6ff }
Ta5d6ff #theme-toggle Ta5d6ff{
Ta5d6ff background: var(--bg2); border: 1px solid var(--border2);
Ta5d6ff color: var(--text-dim); cursor: pointer; font: 16px monospace;
Ta5d6ff padding: 2px 6px; border-radius: 4px; line-height: 1;
Ta5d6ff }
Ta5d6ff #theme-toggle:hover Ta5d6ff{Ta5d6ff background: var(--bg3); color: var(--text); }
Ta5d6ff .overlay-btn Ta5d6ff{
Ta5d6ff background: var(--bg2); border: 1px solid var(--border2);
Ta5d6ff color: var(--text-dim); cursor: pointer; font: 11px monospace;
Ta5d6ff padding: 4px 8px; border-radius: 4px; line-height: 1;
Ta5d6ff }
Ta5d6ff .overlay-btn:hover Ta5d6ff{Ta5d6ff background: var(--bg3); color: var(--text); }
Ta5d6ff .overlay-btn.active Ta5d6ff{Ta5d6ff background: #1a2e3a; border-color: #3a7aaa; color: #7bf; }
Ta5d6ff body.light-theme .overlay-btn.active Ta5d6ff{Ta5d6ff background: #ddeeff; border-color: #3a7aaa; color: #226; }
Ta5d6ff #heatmap-control Ta5d6ff{
Ta5d6ff position: fixed; top: 16px; right: 16px; z-index: 600;
Ta5d6ff }
Ta5d6ff #tileset-select, #size-select Ta5d6ff{
Ta5d6ff background: var(--bg2); border: 1px solid var(--border2); color: var(--text-dim);
Ta5d6ff font: 11px monospace; padding: 2px 4px; border-radius: 4px; cursor: pointer;
Ta5d6ff }
Ta5d6ff #tileset-select:hover, #size-select:hover Ta5d6ff{Ta5d6ff color: var(--text); }
Ta5d6ff #discovery-warning Ta5d6ff{
Ta5d6ff display: none;
Ta5d6ff position: fixed; top: 0; left: 0; right: 0; z-index: 1200;
Ta5d6ff background: #7a1f1f; color: #ffe; border-bottom: 1px solid #b33;
Ta5d6ff font: 12px monospace; text-align: center; padding: 7px 36px 7px 12px;
Ta5d6ff box-shadow: 0 2px 8px rgba(0,0,0,.4);
Ta5d6ff }
Ta5d6ff </style>
Ta5d6ff</head>
Ta5d6ff<body>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffdiscovery-warningTa5d6ff"Ta5d6ff>⚠️ Discovery data unavailable — node list may be incomplete or out of date (rnsd not responding).</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffhover-tipTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffhover-barsTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffhover-rowTa5d6ff"Ta5d6ff>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-colTa5d6ff"Ta5d6ff>
Ta5d6ff <span id=Ta5d6ff"Ta5d6ffhover-rssi-valTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffvbar-lblTa5d6ff"Ta5d6ff>dBm</span>
Ta5d6ff </div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-wrapTa5d6ff"Ta5d6ff><div class=Ta5d6ff"Ta5d6ffvbar-maskTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffhover-rssi-maskTa5d6ff"Ta5d6ff></div></div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-wrapTa5d6ff"Ta5d6ff><div class=Ta5d6ff"Ta5d6ffvbar-maskTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffhover-snr-maskTa5d6ff"Ta5d6ff></div></div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-colTa5d6ff"Ta5d6ff>
Ta5d6ff <span id=Ta5d6ff"Ta5d6ffhover-snr-valTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffvbar-lblTa5d6ff"Ta5d6ff>dB</span>
Ta5d6ff </div>
Ta5d6ff </div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffhover-bars-dlTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffhover-rowTa5d6ff"Ta5d6ff>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-colTa5d6ff"Ta5d6ff>
Ta5d6ff <span id=Ta5d6ff"Ta5d6ffhover-dl-rssi-valTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffvbar-lblTa5d6ff"Ta5d6ff>dBm</span>
Ta5d6ff </div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-wrapTa5d6ff"Ta5d6ff><div class=Ta5d6ff"Ta5d6ffvbar-maskTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffhover-dl-rssi-maskTa5d6ff"Ta5d6ff></div></div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-wrapTa5d6ff"Ta5d6ff><div class=Ta5d6ff"Ta5d6ffvbar-maskTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffhover-dl-snr-maskTa5d6ff"Ta5d6ff></div></div>
Ta5d6ff <div class=Ta5d6ff"Ta5d6ffvbar-colTa5d6ff"Ta5d6ff>
Ta5d6ff <span id=Ta5d6ff"Ta5d6ffhover-dl-snr-valTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffvbar-lblTa5d6ff"Ta5d6ff>dB</span>
Ta5d6ff </div>
Ta5d6ff </div>
Ta5d6ff</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffnodes-open-wrapTa5d6ff"Ta5d6ff><button id=Ta5d6ff"Ta5d6ffnodes-openTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffNode ListTa5d6ff"Ta5d6ff>📑 Node List</button></div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffnodes-panelTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffnodes-headerTa5d6ff"Ta5d6ff><span>📑 Node List</span><button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffnodes-closeTa5d6ff"Ta5d6ff>✕</button></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffnodes-search-barTa5d6ff"Ta5d6ff><input type=Ta5d6ff"Ta5d6fftextTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffnodes-searchTa5d6ff"Ta5d6ff placeholder=Ta5d6ff"Ta5d6ffSearch by name…Ta5d6ff"Ta5d6ff></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffnodes-geo-barTa5d6ff"Ta5d6ff>
Ta5d6ff <select id=Ta5d6ff"Ta5d6ffgeo-countryTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffgeo-selectTa5d6ff"Ta5d6ff><option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>All Countries</option></select>
Ta5d6ff <select id=Ta5d6ff"Ta5d6ffgeo-stateTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffgeo-selectTa5d6ff"Ta5d6ff><option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>All Regions</option></select>
Ta5d6ff <select id=Ta5d6ff"Ta5d6ffgeo-cityTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffgeo-selectTa5d6ff"Ta5d6ff><option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>All Cities</option></select>
Ta5d6ff </div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffnodes-listTa5d6ff"Ta5d6ff></div>
Ta5d6ff</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffdiscovery-containerTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffdiscovery-open-wrapTa5d6ff"Ta5d6ff><button id=Ta5d6ff"Ta5d6ffdiscovery-openTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffDiscoveryTa5d6ff"Ta5d6ff>🌐 Discovery<span id=Ta5d6ff"Ta5d6fftab-dot-rnodeTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6fftab-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#f80Ta5d6ff"Ta5d6ff></span><span id=Ta5d6ff"Ta5d6fftab-dot-backboneTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6fftab-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#48fTa5d6ff"Ta5d6ff></span><span id=Ta5d6ff"Ta5d6fftab-dot-i2pTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6fftab-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#ad4Ta5d6ff"Ta5d6ff></span><span id=Ta5d6ff"Ta5d6fftab-dot-halowTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6fftab-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#0cfTa5d6ff"Ta5d6ff></span></button></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffdiscovery-panelTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffdiscovery-headerTa5d6ff"Ta5d6ff><span>🌐 Discovery</span><button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffdiscovery-closeTa5d6ff"Ta5d6ff>✕</button></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffdiscovery-listTa5d6ff"Ta5d6ff>
Ta5d6ff <label class=Ta5d6ff"Ta5d6ffdisc-itemTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffcheckboxTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffcheck-rnodeTa5d6ff"Ta5d6ff checked>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#f80Ta5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-labelTa5d6ff"Ta5d6ff>RNode</span>
Ta5d6ff </label>
Ta5d6ff <label class=Ta5d6ff"Ta5d6ffdisc-itemTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffcheckboxTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffcheck-backboneTa5d6ff"Ta5d6ff>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#48fTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-labelTa5d6ff"Ta5d6ff>Backbone</span>
Ta5d6ff </label>
Ta5d6ff <label class=Ta5d6ff"Ta5d6ffdisc-itemTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffcheckboxTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffcheck-i2pTa5d6ff"Ta5d6ff>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#ad4Ta5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-labelTa5d6ff"Ta5d6ff>I2P</span>
Ta5d6ff </label>
Ta5d6ff <label class=Ta5d6ff"Ta5d6ffdisc-itemTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffcheckboxTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffcheck-halowTa5d6ff"Ta5d6ff checked>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-dotTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffbackground:#0cfTa5d6ff"Ta5d6ff></span>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-labelTa5d6ff"Ta5d6ff>HaLow</span>
Ta5d6ff </label>
Ta5d6ff </div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffdiscovery-optionsTa5d6ff"Ta5d6ff>
Ta5d6ff <label class=Ta5d6ff"Ta5d6ffdisc-itemTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffcheckboxTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffcheck-labelsTa5d6ff"Ta5d6ff checked>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdisc-labelTa5d6ff"Ta5d6ff>Labels</span>
Ta5d6ff </label>
Ta5d6ff </div>
Ta5d6ff </div>
Ta5d6ff</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffmapTa5d6ff"Ta5d6ff></div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffheatmap-controlTa5d6ff"Ta5d6ff>
Ta5d6ff <button id=Ta5d6ff"Ta5d6ffheatmap-toggleTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffoverlay-btnTa5d6ff"Ta5d6ff>📶 Signal Overlay</button>
Ta5d6ff</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffmap-controlsTa5d6ff"Ta5d6ff>
Ta5d6ff <button id=Ta5d6ff"Ta5d6fftheme-toggleTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffLight / Dark ModeTa5d6ff"Ta5d6ff>☀</button>
Ta5d6ff <select id=Ta5d6ff"Ta5d6ffsize-selectTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffMarker SizeTa5d6ff"Ta5d6ff>
Ta5d6ff <option value=Ta5d6ff"Ta5d6ffsmallTa5d6ff"Ta5d6ff>Small</option>
Ta5d6ff <option value=Ta5d6ff"Ta5d6ffmediumTa5d6ff"Ta5d6ff>Medium</option>
Ta5d6ff <option value=Ta5d6ff"Ta5d6fflargeTa5d6ff"Ta5d6ff>Large</option>
Ta5d6ff <option value=Ta5d6ff"Ta5d6ffxlTa5d6ff"Ta5d6ff>XL</option>
Ta5d6ff </select>
Ta5d6ff <select id=Ta5d6ff"Ta5d6fftileset-selectTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffMap TilesetTa5d6ff"Ta5d6ff></select>
Ta5d6ff</div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffpanel-open-wrapTa5d6ff"Ta5d6ff><button id=Ta5d6ff"Ta5d6ffpanel-openTa5d6ff"Ta5d6ff title=Ta5d6ff"Ta5d6ffSignal Report StreamTa5d6ff"Ta5d6ff>📶 Signal Report Stream</button></div>
Ta5d6ff<div id=Ta5d6ff"Ta5d6ffpanelTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffpanel-headerTa5d6ff"Ta5d6ff><span id=Ta5d6ff"Ta5d6ffpanel-titleTa5d6ff"Ta5d6ff>📶 Signal Report Stream</span><button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6ffpanel-closeTa5d6ff"Ta5d6ff>✕</button></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6fffilter-barTa5d6ff"Ta5d6ff>
Ta5d6ff <div id=Ta5d6ff"Ta5d6fffilter-datesTa5d6ff"Ta5d6ff>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffdateTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6fffilter-fromTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffdate-inputTa5d6ff"Ta5d6ff>
Ta5d6ff <span class=Ta5d6ff"Ta5d6ffdate-sepTa5d6ff"Ta5d6ff>—</span>
Ta5d6ff <input type=Ta5d6ff"Ta5d6ffdateTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6fffilter-toTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6ffdate-inputTa5d6ff"Ta5d6ff>
Ta5d6ff </div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6fffilter-btnsTa5d6ff"Ta5d6ff>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff data-preset=Ta5d6ff"Ta5d6ffdayTa5d6ff"Ta5d6ff>Day</button>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff data-preset=Ta5d6ff"Ta5d6ffweekTa5d6ff"Ta5d6ff>Week</button>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff data-preset=Ta5d6ff"Ta5d6ffmonthTa5d6ff"Ta5d6ff>Month</button>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff data-preset=Ta5d6ff"Ta5d6ffyearTa5d6ff"Ta5d6ff>Year</button>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6fffilter-applyTa5d6ff"Ta5d6ff>Filter</button>
Ta5d6ff <button class=Ta5d6ff"Ta5d6ffpanel-btnTa5d6ff"Ta5d6ff id=Ta5d6ff"Ta5d6fffilter-clearTa5d6ff"Ta5d6ff>Clear</button>
Ta5d6ff </div>
Ta5d6ff </div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffpanel-listTa5d6ff"Ta5d6ff></div>
Ta5d6ff <div id=Ta5d6ff"Ta5d6ffstatusTa5d6ff"Ta5d6ff><span id=Ta5d6ff"Ta5d6ffstatus-timeTa5d6ff"Ta5d6ff class=Ta5d6ff"Ta5d6fftsTa5d6ff"Ta5d6ff>Waiting…</span><span id=Ta5d6ff"Ta5d6ffstatus-totalTa5d6ff"Ta5d6ff></span></div>
Ta5d6ff</div>
Ta5d6ff<script src=Ta5d6ff"Ta5d6ffhttps://unpkg.com/leaflet@1.9.4/dist/leaflet.jsTa5d6ff"Ta5d6ff></script>
Ta5d6ff<script src=Ta5d6ff"Ta5d6ffhttps://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.jsTa5d6ff"Ta5d6ff></script>
Ta5d6ff<script>
Ta5d6ffconst map = L.map(Ta5d6ff'Ta5d6ffmapTa5d6ff'Ta5d6ff).setView([Ta5d6ff{{Ta5d6ff init_lat }}, Ta5d6ff{{Ta5d6ff init_lon }}], Ta5d6ff{{Ta5d6ff init_zoom }});
Ta5d6ffconst TILE_ATTR = Ta5d6ff'Ta5d6ff© <a href=Ta5d6ff"Ta5d6ffhttps://www.openstreetmap.org/copyrightTa5d6ff"Ta5d6ff>OpenStreetMap</a> contributors © <a href=Ta5d6ff"Ta5d6ffhttps://carto.com/attributionsTa5d6ff"Ta5d6ff>CARTO</a>Ta5d6ff'Ta5d6ff;
Ta5d6ffconst TILESETS = [
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ffDarkTa5d6ff'Ta5d6ff, url: Ta5d6ff'Ta5d6ffhttps://Tffd700{s}Ta5d6ff.basemaps.cartocdn.com/dark_all/Tffd700{z}Ta5d6ff/Tffd700{x}Ta5d6ff/Tffd700{y}Ta5d6ff.pngTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ffVoyagerTa5d6ff'Ta5d6ff, url: Ta5d6ff'Ta5d6ffhttps://Tffd700{s}Ta5d6ff.basemaps.cartocdn.com/rastertiles/voyager/Tffd700{z}Ta5d6ff/Tffd700{x}Ta5d6ff/Tffd700{y}Ta5d6ff.pngTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ffVoyager (no labels)Ta5d6ff'Ta5d6ff, url: Ta5d6ff'Ta5d6ffhttps://Tffd700{s}Ta5d6ff.basemaps.cartocdn.com/rastertiles/voyager_nolabels/Tffd700{z}Ta5d6ff/Tffd700{x}Ta5d6ff/Tffd700{y}Ta5d6ff.pngTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ffLightTa5d6ff'Ta5d6ff, url: Ta5d6ff'Ta5d6ffhttps://Tffd700{s}Ta5d6ff.basemaps.cartocdn.com/light_all/Tffd700{z}Ta5d6ff/Tffd700{x}Ta5d6ff/Tffd700{y}Ta5d6ff.pngTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ffOSMTa5d6ff'Ta5d6ff, url: Ta5d6ff'Ta5d6ffhttps://Tffd700{s}Ta5d6ff.tile.openstreetmap.org/Tffd700{z}Ta5d6ff/Tffd700{x}Ta5d6ff/Tffd700{y}Ta5d6ff.pngTa5d6ff'Ta5d6ff },
Ta5d6ff];
Ta5d6fflet tileLayer = L.tileLayer(TILESETS[0].url, Ta5d6ff{Ta5d6ff attribution: TILE_ATTR, maxZoom: 19 }).addTo(map);
Ta5d6ffconst heat = L.heatLayer([], Ta5d6ff{
Ta5d6ff radius: 20,
Ta5d6ff blur: 15,
Ta5d6ff minOpacity: 0.4,
Ta5d6ff maxZoom: 17,
Ta5d6ff gradient: Ta5d6ff{Ta5d6ff 0.0: Ta5d6ff'Ta5d6ffblueTa5d6ff'Ta5d6ff, 0.4: Ta5d6ff'Ta5d6ffcyanTa5d6ff'Ta5d6ff, 0.6: Ta5d6ff'Ta5d6fflimeTa5d6ff'Ta5d6ff, 0.8: Ta5d6ff'Ta5d6ffyellowTa5d6ff'Ta5d6ff, 1.0: Ta5d6ff'Ta5d6ffredTa5d6ff'Ta5d6ff },
Ta5d6ff});
Ta5d6ffconst heatDl = L.heatLayer([], Ta5d6ff{
Ta5d6ff radius: 20,
Ta5d6ff blur: 15,
Ta5d6ff minOpacity: 0.4,
Ta5d6ff maxZoom: 17,
Ta5d6ff gradient: Ta5d6ff{Ta5d6ff 0.0: Ta5d6ff'Ta5d6ffpurpleTa5d6ff'Ta5d6ff, 0.3: Ta5d6ff'Ta5d6ff#ff00ccTa5d6ff'Ta5d6ff, 0.6: Ta5d6ff'Ta5d6fforangeTa5d6ff'Ta5d6ff, 0.85: Ta5d6ff'Ta5d6ffyellowTa5d6ff'Ta5d6ff, 1.0: Ta5d6ff'Ta5d6ff#ffff80Ta5d6ff'Ta5d6ff },
Ta5d6ff});
Ta5d6ffconst OVERLAY_MODES = [
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ff📶 Signal OverlayTa5d6ff'Ta5d6ff, ul: false, dl: false },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ff🔺 UplinkTa5d6ff'Ta5d6ff, ul: true, dl: false },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ff🔻 DownlinkTa5d6ff'Ta5d6ff, ul: false, dl: true },
Ta5d6ff Ta5d6ff{Ta5d6ff label: Ta5d6ff'Ta5d6ff🔺🔻 Uplink+DownlinkTa5d6ff'Ta5d6ff, ul: true, dl: true },
Ta5d6ff];
Ta5d6fflet overlayModeIdx = parseInt(localStorage.getItem(Ta5d6ff'Ta5d6ffoverlay-modeTa5d6ff'Ta5d6ff) || Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff, 10);
Ta5d6ffif (overlayModeIdx < 0 || overlayModeIdx >= OVERLAY_MODES.length) overlayModeIdx = 0;
Ta5d6ffconst heatBtn = document.getElementById(Ta5d6ff'Ta5d6ffheatmap-toggleTa5d6ff'Ta5d6ff);
Ta5d6fffunction applyOverlayMode() Ta5d6ff{
Ta5d6ff const m = OVERLAY_MODES[overlayModeIdx];
Ta5d6ff heatBtn.textContent = m.label;
Ta5d6ff heatBtn.classList.toggle(Ta5d6ff'Ta5d6ffactiveTa5d6ff'Ta5d6ff, m.ul || m.dl);
Ta5d6ff m.ul ? heat.addTo(map) : map.removeLayer(heat);
Ta5d6ff m.dl ? heatDl.addTo(map) : map.removeLayer(heatDl);
Ta5d6ff}
Ta5d6ffapplyOverlayMode();
Ta5d6ffheatBtn.addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff overlayModeIdx = (overlayModeIdx + 1) Ta5d6ff%Ta5d6ff OVERLAY_MODES.length;
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffoverlay-modeTa5d6ff'Ta5d6ff, overlayModeIdx);
Ta5d6ff applyOverlayMode();
Ta5d6ff});
Ta5d6fffunction fetchDlOverlay() Ta5d6ff{
Ta5d6ff fetch(Ta5d6ff'Ta5d6ff/api/geojson?direction=downTa5d6ff'Ta5d6ff)
Ta5d6ff .then(r => r.json())
Ta5d6ff .then(fc => Ta5d6ff{
Ta5d6ff heatDl.setLatLngs((fc.features || []).map(f => [
Ta5d6ff f.geometry.coordinates[1],
Ta5d6ff f.geometry.coordinates[0],
Ta5d6ff rssiToIntensity(f.properties.rssi),
Ta5d6ff ]));
Ta5d6ff })
Ta5d6ff .catch(() => Tffd700{}Ta5d6ff);
Ta5d6ff}
Ta5d6fffetchDlOverlay();
Ta5d6ffsetInterval(fetchDlOverlay, 30000);
Ta5d6ff// RSSI range: -50 dBm (strong) → 1.0, -120 dBm (very weak) → 0.0
Ta5d6fffunction rssiToIntensity(rssi) Ta5d6ff{
Ta5d6ff if (rssi === null) return 0.3;
Ta5d6ff return Math.min(1, Math.max(0, (rssi + 120) / 70));
Ta5d6ff}
Ta5d6fflet lastTs = 0;
Ta5d6fflet total = 0;
Ta5d6fflet fitDone = Ta5d6ff{{Ta5d6ff fit_done }};
Ta5d6ffconst DEFAULT_COUNTRY = Ta5d6ff{{Ta5d6ff default_country | safe }};
Ta5d6fflet filterActive = false;
Ta5d6fflet pollTimer = null;
Ta5d6ffconst bounds = [];
Ta5d6ffconst allPoints = [];
Ta5d6ffconst hoverTip = document.getElementById(Ta5d6ff'Ta5d6ffhover-tipTa5d6ff'Ta5d6ff);
Ta5d6fffunction pctToColor(pct) Ta5d6ff{
Ta5d6ff const t = Math.min(1, Math.max(0, pct / 100));
Ta5d6ff if (t < 0.5) Ta5d6ff{
Ta5d6ff const r = Math.round(204 + (204 - 204) * (t * 2));
Ta5d6ff const g = Math.round(51 + (204 - 51) * (t * 2));
Ta5d6ff return `rgb($Tffd700{r}Ta5d6ff,$Tffd700{g}Ta5d6ff,51)`;
Ta5d6ff } else Ta5d6ff{
Ta5d6ff const r = Math.round(204 - 204 * ((t - 0.5) * 2));
Ta5d6ff const g = Math.round(204 - 51 * ((t - 0.5) * 2));
Ta5d6ff return `rgb($Tffd700{r}Ta5d6ff,$Tffd700{g}Ta5d6ff,51)`;
Ta5d6ff }
Ta5d6ff}
Ta5d6ff// Fills one gauge row (idPrefix Ta5d6ff'Ta5d6ff'Ta5d6ff = uplink, Ta5d6ff'Ta5d6ffdl-Ta5d6ff'Ta5d6ff = downlink) from rssi/snr, which may be
Ta5d6ff// null/undefined (no reading for this direction on this point) — rendered as empty/grey.
Ta5d6fffunction setGaugeRow(idPrefix, rssi, snr) Ta5d6ff{
Ta5d6ff const hasRssi = rssi !== null && rssi !== undefined;
Ta5d6ff const hasSnr = snr !== null && snr !== undefined;
Ta5d6ff const rssiPct = hasRssi ? Math.min(100, Math.max(0, (rssi + 110) / 80 * 100)) : 0;
Ta5d6ff const snrPct = hasSnr ? Math.min(100, Math.max(0, (snr + 20) / 30 * 100)) : 0;
Ta5d6ff document.getElementById(`hover-$Tffd700{idPrefix}Ta5d6ffrssi-mask`).style.height = (100 - rssiPct) + Ta5d6ff'Ta5d6ff%Ta5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(`hover-$Tffd700{idPrefix}Ta5d6ffsnr-mask`).style.height = (100 - snrPct) + Ta5d6ff'Ta5d6ff%Ta5d6ff'Ta5d6ff;
Ta5d6ff const rssiEl = document.getElementById(`hover-$Tffd700{idPrefix}Ta5d6ffrssi-val`);
Ta5d6ff const snrEl = document.getElementById(`hover-$Tffd700{idPrefix}Ta5d6ffsnr-val`);
Ta5d6ff const rssiLbl = rssiEl.nextElementSibling;
Ta5d6ff const snrLbl = snrEl.nextElementSibling;
Ta5d6ff rssiEl.textContent = hasRssi ? rssi : Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff snrEl.textContent = hasSnr ? snr : Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff const rc = hasRssi ? pctToColor(rssiPct) : Ta5d6ff'Ta5d6ff#555Ta5d6ff'Ta5d6ff;
Ta5d6ff const sc = hasSnr ? pctToColor(snrPct) : Ta5d6ff'Ta5d6ff#555Ta5d6ff'Ta5d6ff;
Ta5d6ff rssiEl.style.color = rc; rssiLbl.style.color = rc;
Ta5d6ff snrEl.style.color = sc; snrLbl.style.color = sc;
Ta5d6ff}
Ta5d6ffconst hoverBarsUl = document.getElementById(Ta5d6ff'Ta5d6ffhover-barsTa5d6ff'Ta5d6ff);
Ta5d6ffconst hoverBarsDl = document.getElementById(Ta5d6ff'Ta5d6ffhover-bars-dlTa5d6ff'Ta5d6ff);
Ta5d6ff// showUl/showDl reflect the current overlay mode; the downlink row additionally only shows
Ta5d6ff// when the hovered point actually has a downlink reading (not every point does).
Ta5d6fffunction showHoverTip(p, cx, cy, showUl, showDl) Ta5d6ff{
Ta5d6ff if (showUl) Ta5d6ff{Ta5d6ff setGaugeRow(Ta5d6ff'Ta5d6ff'Ta5d6ff, p.rssi, p.snr); hoverBarsUl.style.display = Ta5d6ff'Ta5d6ffflexTa5d6ff'Ta5d6ff; }
Ta5d6ff else hoverBarsUl.style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff const hasDl = p.dl_rssi !== null && p.dl_rssi !== undefined;
Ta5d6ff if (showDl && hasDl) Ta5d6ff{Ta5d6ff setGaugeRow(Ta5d6ff'Ta5d6ffdl-Ta5d6ff'Ta5d6ff, p.dl_rssi, p.dl_snr); hoverBarsDl.style.display = Ta5d6ff'Ta5d6ffflexTa5d6ff'Ta5d6ff; }
Ta5d6ff else hoverBarsDl.style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff hoverTip.style.left = (cx + 14) + Ta5d6ff'Ta5d6ffpxTa5d6ff'Ta5d6ff;
Ta5d6ff hoverTip.style.top = (cy - 14) + Ta5d6ff'Ta5d6ffpxTa5d6ff'Ta5d6ff;
Ta5d6ff hoverTip.style.display = Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6ffmap.on(Ta5d6ff'Ta5d6ffmousemoveTa5d6ff'Ta5d6ff, e => Ta5d6ff{
Ta5d6ff const cursor = e.containerPoint;
Ta5d6ff const mode = OVERLAY_MODES[overlayModeIdx];
Ta5d6ff if (!mode.ul && !mode.dl) Ta5d6ff{Ta5d6ff hoverTip.style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff; return; }
Ta5d6ff // Downlink-only mode: only bother matching points that actually have a downlink reading,
Ta5d6ff // so hovering finds whateverTa5d6ff'Ta5d6ffs really rendered on the downlink heat layer. In Uplink or
Ta5d6ff // Both mode, search all points — virtually everything has an uplink reading.
Ta5d6ff const dlOnly = mode.dl && !mode.ul;
Ta5d6ff let nearest = null, minDist = 30;
Ta5d6ff for (const p of allPoints) Ta5d6ff{
Ta5d6ff if (dlOnly && (p.dl_rssi === null || p.dl_rssi === undefined)) continue;
Ta5d6ff const pt = map.latLngToContainerPoint([p.lat, p.lon]);
Ta5d6ff const d = Math.hypot(pt.x - cursor.x, pt.y - cursor.y);
Ta5d6ff if (d < minDist) Ta5d6ff{Ta5d6ff minDist = d; nearest = p; }
Ta5d6ff }
Ta5d6ff if (nearest) showHoverTip(nearest, e.originalEvent.clientX, e.originalEvent.clientY, mode.ul, mode.dl);
Ta5d6ff else hoverTip.style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff});
Ta5d6ffmap.on(Ta5d6ff'Ta5d6ffmouseoutTa5d6ff'Ta5d6ff, () => Ta5d6ff{Ta5d6ff hoverTip.style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff; });
Ta5d6ffconst panelList = document.getElementById(Ta5d6ff'Ta5d6ffpanel-listTa5d6ff'Ta5d6ff);
Ta5d6fffunction makeHashSpan(hash) Ta5d6ff{
Ta5d6ff const span = document.createElement(Ta5d6ff'Ta5d6ffspanTa5d6ff'Ta5d6ff);
Ta5d6ff span.className = Ta5d6ff'Ta5d6ffentry-hashTa5d6ff'Ta5d6ff;
Ta5d6ff span.textContent = hash;
Ta5d6ff span.addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, (e) => Ta5d6ff{
Ta5d6ff e.stopPropagation();
Ta5d6ff navigator.clipboard.writeText(hash);
Ta5d6ff span.textContent = Ta5d6ff'Ta5d6ffCopied!Ta5d6ff'Ta5d6ff;
Ta5d6ff setTimeout(() => Ta5d6ff{Ta5d6ff span.textContent = hash; }, 1000);
Ta5d6ff });
Ta5d6ff return span;
Ta5d6ff}
Ta5d6ffconst MAX_ENTRIES = 50;
Ta5d6fffunction addPanelEntry(p) Ta5d6ff{
Ta5d6ff const d = new Date(p.ts * 1000);
Ta5d6ff const t = d.toLocaleTimeString([], Ta5d6ff{Ta5d6ffhour: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, minute: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, second: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour12: false});
Ta5d6ff const dt = d.toLocaleString([], Ta5d6ff{Ta5d6ffyear: Ta5d6ff'Ta5d6ffnumericTa5d6ff'Ta5d6ff, month: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, day: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, minute: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, second: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour12: false});
Ta5d6ff const lat = p.lat.toFixed(5);
Ta5d6ff const lon = p.lon.toFixed(5);
Ta5d6ff function mkBar(pct, text, isQ = false) Ta5d6ff{
Ta5d6ff const p = Math.min(100, Math.max(0, pct));
Ta5d6ff const cls = isQ ? Ta5d6ff'Ta5d6ffrf-bar rf-bar-qTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffrf-barTa5d6ff'Ta5d6ff;
Ta5d6ff return `<span class=Ta5d6ff"Ta5d6ff$Tffd700{cls}Ta5d6ff"Ta5d6ff><span class=Ta5d6ff"Ta5d6ffrf-maskTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffwidth:$Ta5d6ff{Ta5d6ff100-p}Ta5d6ff%Ta5d6ff"Ta5d6ff></span><span class=Ta5d6ff"Ta5d6ffrf-textTa5d6ff"Ta5d6ff>$Tffd700{text}Ta5d6ff</span></span>`;
Ta5d6ff }
Ta5d6ff let rf = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (p.rssi !== null) rf += `<span class=Ta5d6ff"Ta5d6ffrf-labelTa5d6ff"Ta5d6ff>RSSI</span>` + mkBar((p.rssi + 110) / 80 * 100, `$Tffd700{p.rssi}Ta5d6ffdBm`) + Ta5d6ff'Ta5d6ff Ta5d6ff'Ta5d6ff;
Ta5d6ff if (p.snr !== null) rf += `<span class=Ta5d6ff"Ta5d6ffrf-labelTa5d6ff"Ta5d6ff>SNR</span>` + mkBar((p.snr + 20) / 30 * 100, `$Tffd700{p.snr}Ta5d6ffdB`) + Ta5d6ff'Ta5d6ff Ta5d6ff'Ta5d6ff;
Ta5d6ff if (p.q !== null) rf += `<span class=Ta5d6ff"Ta5d6ffrf-labelTa5d6ff"Ta5d6ff style=Ta5d6ff"Ta5d6ffmargin-left:autoTa5d6ff"Ta5d6ff>Q</span>` + mkBar(p.q, `$Tffd700{p.q}Ta5d6ff%Ta5d6ff`, true);
Ta5d6ff const div = document.createElement(Ta5d6ff'Ta5d6ffdivTa5d6ff'Ta5d6ff);
Ta5d6ff div.className = Ta5d6ff'Ta5d6ffentryTa5d6ff'Ta5d6ff;
Ta5d6ff div.innerHTML =
Ta5d6ff `<div class=Ta5d6ff"Ta5d6ffentry-timeTa5d6ff"Ta5d6ff><span class=Ta5d6ff"Ta5d6fftsTa5d6ff"Ta5d6ff data-date=Ta5d6ff"Ta5d6ff$Tffd700{dt}Ta5d6ff"Ta5d6ff>$Tffd700{t}Ta5d6ff</span> <span class=Ta5d6ff"Ta5d6ffentry-fromTa5d6ff"Ta5d6ff>from </span></div>` +
Ta5d6ff `<div style=Ta5d6ff"Ta5d6ffdisplay:flex;align-items:centerTa5d6ff"Ta5d6ff>$Tffd700{rf}Ta5d6ff</div>` +
Ta5d6ff `<div style=Ta5d6ff"Ta5d6ffdisplay:flex;align-items:center;justify-content:space-betweenTa5d6ff"Ta5d6ff>` +
Ta5d6ff `<div class=Ta5d6ff"Ta5d6ffentry-coordsTa5d6ff"Ta5d6ff data-lat=Ta5d6ff"Ta5d6ff$Tffd700{p.lat}Ta5d6ff"Ta5d6ff data-lon=Ta5d6ff"Ta5d6ff$Tffd700{p.lon}Ta5d6ff"Ta5d6ff>$Tffd700{lat}Ta5d6ff, $Tffd700{lon}Ta5d6ff</div>` +
Ta5d6ff `<span class=Ta5d6ff"Ta5d6ffsrc-pill src-$Ta5d6ff{Ta5d6ffp.source || Ta5d6ff'Ta5d6ffannTa5d6ff'Ta5d6ff}Ta5d6ff"Ta5d6ff>$Ta5d6ff{Ta5d6ffp.source === Ta5d6ff'Ta5d6ffpktTa5d6ff'Ta5d6ff ? Ta5d6ff'Ta5d6ffPacketTa5d6ff'Ta5d6ff : p.source === Ta5d6ff'Ta5d6ffrelayTa5d6ff'Ta5d6ff ? Ta5d6ff'Ta5d6ffRelayTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffAnnounceTa5d6ff'Ta5d6ff}</span>` +
Ta5d6ff `</div>`;
Ta5d6ff div.querySelector(Ta5d6ff'Ta5d6ff.entry-fromTa5d6ff'Ta5d6ff).appendChild(makeHashSpan(p.sender_hash));
Ta5d6ff div.querySelector(Ta5d6ff'Ta5d6ff.entry-coordsTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => map.setView([p.lat, p.lon], Math.max(map.getZoom(), 15)));
Ta5d6ff panelList.prepend(div);
Ta5d6ff while (panelList.children.length > MAX_ENTRIES)
Ta5d6ff panelList.removeChild(panelList.lastChild);
Ta5d6ff}
Ta5d6fffunction resetView() Ta5d6ff{
Ta5d6ff heat.setLatLngs([]);
Ta5d6ff panelList.innerHTML = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff bounds.length = 0;
Ta5d6ff allPoints.length = 0;
Ta5d6ff total = 0;
Ta5d6ff fitDone = false;
Ta5d6ff}
Ta5d6fffunction loadPoints(url) Ta5d6ff{
Ta5d6ff fetch(url)
Ta5d6ff .then(r => r.json())
Ta5d6ff .then(pts => Ta5d6ff{
Ta5d6ff if (pts.length === 0) return;
Ta5d6ff pts.forEach(p => Ta5d6ff{
Ta5d6ff heat.addLatLng([p.lat, p.lon, rssiToIntensity(p.rssi)]);
Ta5d6ff bounds.push([p.lat, p.lon]);
Ta5d6ff allPoints.push(p);
Ta5d6ff if (p.ts > lastTs) lastTs = p.ts;
Ta5d6ff addPanelEntry(p);
Ta5d6ff });
Ta5d6ff total += pts.length;
Ta5d6ff const lastD = new Date(lastTs * 1000);
Ta5d6ff const ts = lastD.toLocaleTimeString([], Ta5d6ff{Ta5d6ffhour: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, minute: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, second: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour12: false});
Ta5d6ff const tsFull = lastD.toLocaleString([], Ta5d6ff{Ta5d6ffyear: Ta5d6ff'Ta5d6ffnumericTa5d6ff'Ta5d6ff, month: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, day: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, minute: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, second: Ta5d6ff'Ta5d6ff2-digitTa5d6ff'Ta5d6ff, hour12: false});
Ta5d6ff const statusTime = document.getElementById(Ta5d6ff'Ta5d6ffstatus-timeTa5d6ff'Ta5d6ff);
Ta5d6ff statusTime.textContent = (filterActive ? Ta5d6ff'Ta5d6ffFiltered to Ta5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffLast announce at Ta5d6ff'Ta5d6ff) + ts;
Ta5d6ff statusTime.dataset.date = tsFull;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffstatus-totalTa5d6ff'Ta5d6ff).textContent = Ta5d6ff'Ta5d6ffTotal: Ta5d6ff'Ta5d6ff + total + Ta5d6ff'Ta5d6ff pointTa5d6ff'Ta5d6ff + (total !== 1 ? Ta5d6ff'Ta5d6ffsTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ff'Ta5d6ff);
Ta5d6ff if (!fitDone && bounds.length > 0) Ta5d6ff{
Ta5d6ff if (bounds.length === 1) map.setView(bounds[0], 15);
Ta5d6ff else map.fitBounds(bounds, Ta5d6ff{Ta5d6ff padding: [40, 40] });
Ta5d6ff fitDone = true;
Ta5d6ff }
Ta5d6ff })
Ta5d6ff .catch(() => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffstatus-timeTa5d6ff'Ta5d6ff).textContent = Ta5d6ff'Ta5d6ffDB unavailable — retrying…Ta5d6ff'Ta5d6ff;
Ta5d6ff });
Ta5d6ff}
Ta5d6fffunction toDateStr(d) Ta5d6ff{
Ta5d6ff return d.getFullYear() + Ta5d6ff'Ta5d6ff-Ta5d6ff'Ta5d6ff +
Ta5d6ff String(d.getMonth() + 1).padStart(2, Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff) + Ta5d6ff'Ta5d6ff-Ta5d6ff'Ta5d6ff +
Ta5d6ff String(d.getDate()).padStart(2, Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff);
Ta5d6ff}
Ta5d6ffdocument.querySelectorAll(Ta5d6ff'Ta5d6ff[data-preset]Ta5d6ff'Ta5d6ff).forEach(btn => Ta5d6ff{
Ta5d6ff btn.addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff const now = new Date();
Ta5d6ff const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
Ta5d6ff let from;
Ta5d6ff switch (btn.dataset.preset) Ta5d6ff{
Ta5d6ff case Ta5d6ff'Ta5d6ffdayTa5d6ff'Ta5d6ff: from = today; break;
Ta5d6ff case Ta5d6ff'Ta5d6ffweekTa5d6ff'Ta5d6ff: from = new Date(today); from.setDate(today.getDate() - 6); break;
Ta5d6ff case Ta5d6ff'Ta5d6ffmonthTa5d6ff'Ta5d6ff: from = new Date(today.getFullYear(), today.getMonth(), 1); break;
Ta5d6ff case Ta5d6ff'Ta5d6ffyearTa5d6ff'Ta5d6ff: from = new Date(today.getFullYear(), 0, 1); break;
Ta5d6ff }
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-fromTa5d6ff'Ta5d6ff).value = toDateStr(from);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-toTa5d6ff'Ta5d6ff).value = toDateStr(today);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-applyTa5d6ff'Ta5d6ff).click();
Ta5d6ff });
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6fffilter-applyTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff const fromVal = document.getElementById(Ta5d6ff'Ta5d6fffilter-fromTa5d6ff'Ta5d6ff).value;
Ta5d6ff const toVal = document.getElementById(Ta5d6ff'Ta5d6fffilter-toTa5d6ff'Ta5d6ff).value;
Ta5d6ff if (!fromVal && !toVal) return;
Ta5d6ff const fromTs = fromVal ? Math.floor(new Date(fromVal + Ta5d6ff'Ta5d6ffT00:00:00Ta5d6ff'Ta5d6ff).getTime() / 1000) : 0;
Ta5d6ff const toTs = toVal ? Math.floor(new Date(toVal + Ta5d6ff'Ta5d6ffT23:59:59Ta5d6ff'Ta5d6ff).getTime() / 1000) : Math.floor(Date.now() / 1000);
Ta5d6ff filterActive = true;
Ta5d6ff clearInterval(pollTimer);
Ta5d6ff resetView();
Ta5d6ff lastTs = 0;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanel-titleTa5d6ff'Ta5d6ff).textContent = Ta5d6ff'Ta5d6ff📶 Signal Report Stream (Filtered)Ta5d6ff'Ta5d6ff;
Ta5d6ff loadPoints(`/api/points?from_ts=$Tffd700{fromTs}Ta5d6ff&to_ts=$Tffd700{toTs}Ta5d6ff`);
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6fffilter-clearTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff filterActive = false;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-fromTa5d6ff'Ta5d6ff).value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-toTa5d6ff'Ta5d6ff).value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanel-titleTa5d6ff'Ta5d6ff).textContent = Ta5d6ff'Ta5d6ff📶 Signal Report StreamTa5d6ff'Ta5d6ff;
Ta5d6ff resetView();
Ta5d6ff lastTs = 0;
Ta5d6ff loadPoints(Ta5d6ff'Ta5d6ff/api/points?since=0Ta5d6ff'Ta5d6ff);
Ta5d6ff pollTimer = setInterval(() => loadPoints(Ta5d6ff'Ta5d6ff/api/points?since=Ta5d6ff'Ta5d6ff + lastTs), 5000);
Ta5d6ff});
Ta5d6fffunction fetchPoints() Ta5d6ff{
Ta5d6ff if (!filterActive) loadPoints(Ta5d6ff'Ta5d6ff/api/points?since=Ta5d6ff'Ta5d6ff + lastTs);
Ta5d6ff}
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffdiscovery-closeTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffdiscovery-panelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffdiscovery-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff;
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffdiscovery-openTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffdiscovery-panelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffflexTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffdiscovery-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffpanel-closeTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanel-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffheatmap-controlTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff map.invalidateSize();
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffpanel-openTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffflexTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffpanel-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffheatmap-controlTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff map.invalidateSize();
Ta5d6ff});
Ta5d6fffunction toDateInput(ts) Ta5d6ff{
Ta5d6ff const d = new Date(ts * 1000);
Ta5d6ff return d.getFullYear() + Ta5d6ff'Ta5d6ff-Ta5d6ff'Ta5d6ff +
Ta5d6ff String(d.getMonth() + 1).padStart(2, Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff) + Ta5d6ff'Ta5d6ff-Ta5d6ff'Ta5d6ff +
Ta5d6ff String(d.getDate()).padStart(2, Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff);
Ta5d6ff}
Ta5d6fffetch(Ta5d6ff'Ta5d6ff/api/rangeTa5d6ff'Ta5d6ff).then(r => r.json()).then(range => Ta5d6ff{
Ta5d6ff if (range.min_ts) document.getElementById(Ta5d6ff'Ta5d6fffilter-fromTa5d6ff'Ta5d6ff).value = toDateInput(range.min_ts);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fffilter-toTa5d6ff'Ta5d6ff).value = toDateInput(Math.floor(Date.now() / 1000));
Ta5d6ff});
Ta5d6ffsetTimeout(() => Ta5d6ff{Ta5d6ff map.invalidateSize(); fetchPoints(); }, 100);
Ta5d6ffpollTimer = setInterval(fetchPoints, 5000);
Ta5d6ff// ---- Follow / geolocation ----
Ta5d6fflet _posMarker = null;
Ta5d6fflet _watchId = null;
Ta5d6fflet _followActive = false;
Ta5d6ffconst _followControl = L.Control.extend(Ta5d6ff{
Ta5d6ff options: Ta5d6ff{Ta5d6ff position: Ta5d6ff'Ta5d6fftopleftTa5d6ff'Ta5d6ff },
Ta5d6ff onAdd() Ta5d6ff{
Ta5d6ff const container = L.DomUtil.create(Ta5d6ff'Ta5d6ffdivTa5d6ff'Ta5d6ff, Ta5d6ff'Ta5d6ffleaflet-bar leaflet-controlTa5d6ff'Ta5d6ff);
Ta5d6ff const a = L.DomUtil.create(Ta5d6ff'Ta5d6ffaTa5d6ff'Ta5d6ff, Ta5d6ff'Ta5d6fffollow-btnTa5d6ff'Ta5d6ff, container);
Ta5d6ff a.innerHTML = Ta5d6ff'Ta5d6ff<span style=Ta5d6ff"Ta5d6ffposition:relative;top:2pxTa5d6ff"Ta5d6ff>📍</span>Ta5d6ff'Ta5d6ff;
Ta5d6ff a.href = Ta5d6ff'Ta5d6ff#Ta5d6ff'Ta5d6ff;
Ta5d6ff a.title = Ta5d6ff'Ta5d6ffFollow my locationTa5d6ff'Ta5d6ff;
Ta5d6ff L.DomEvent.disableClickPropagation(container);
Ta5d6ff L.DomEvent.on(a, Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, e => Ta5d6ff{Ta5d6ff L.DomEvent.preventDefault(e); _toggleFollow(a); });
Ta5d6ff return container;
Ta5d6ff }
Ta5d6ff});
Ta5d6ffnew _followControl().addTo(map);
Ta5d6fffunction _onPosition(pos) Ta5d6ff{
Ta5d6ff const lat = pos.coords.latitude, lon = pos.coords.longitude;
Ta5d6ff if (!_posMarker) Ta5d6ff{
Ta5d6ff _posMarker = L.circleMarker([lat, lon], Ta5d6ff{
Ta5d6ff radius: 7, color: Ta5d6ff'Ta5d6ff#fffTa5d6ff'Ta5d6ff, fillColor: Ta5d6ff'Ta5d6ff#48fTa5d6ff'Ta5d6ff, fillOpacity: 0.95, opacity: 1, weight: 2, zIndexOffset: 1000,
Ta5d6ff }).addTo(map);
Ta5d6ff _posMarker.bindTooltip(Ta5d6ff'Ta5d6ffYouTa5d6ff'Ta5d6ff, Ta5d6ff{Ta5d6ff permanent: true, direction: Ta5d6ff'Ta5d6ffrightTa5d6ff'Ta5d6ff, className: Ta5d6ff'Ta5d6ffnode-label node-label-backboneTa5d6ff'Ta5d6ff, offset: [8, 0] });
Ta5d6ff } else Ta5d6ff{
Ta5d6ff _posMarker.setLatLng([lat, lon]);
Ta5d6ff }
Ta5d6ff if (_followActive) map.setView([lat, lon], map.getZoom());
Ta5d6ff}
Ta5d6fffunction _onPositionError(err) Ta5d6ff{
Ta5d6ff console.warn(Ta5d6ff'Ta5d6ffGeolocation:Ta5d6ff'Ta5d6ff, err.message);
Ta5d6ff}
Ta5d6fffunction _toggleFollow(a) Ta5d6ff{
Ta5d6ff _followActive = !_followActive;
Ta5d6ff a.classList.toggle(Ta5d6ff'Ta5d6ffactiveTa5d6ff'Ta5d6ff, _followActive);
Ta5d6ff if (_followActive) Ta5d6ff{
Ta5d6ff if (_watchId === null) Ta5d6ff{
Ta5d6ff _watchId = navigator.geolocation.watchPosition(_onPosition, _onPositionError, Ta5d6ff{Ta5d6ff enableHighAccuracy: true, maximumAge: 1000 });
Ta5d6ff }
Ta5d6ff } else Ta5d6ff{
Ta5d6ff if (_watchId !== null) Ta5d6ff{Ta5d6ff navigator.geolocation.clearWatch(_watchId); _watchId = null; }
Ta5d6ff if (_posMarker) Ta5d6ff{Ta5d6ff map.removeLayer(_posMarker); _posMarker = null; }
Ta5d6ff }
Ta5d6ff}
Ta5d6ff// ---- Marker size scale ----
Ta5d6ffconst MARKER_SCALES = Ta5d6ff{Ta5d6ff small: 1.0, medium: 1.25, large: 1.5, xl: 1.75 };
Ta5d6fflet markerScale = MARKER_SCALES[localStorage.getItem(Ta5d6ff'Ta5d6ffmarker-sizeTa5d6ff'Ta5d6ff) || Ta5d6ff'Ta5d6ffsmallTa5d6ff'Ta5d6ff];
Ta5d6fffunction _sr(base) Ta5d6ff{Ta5d6ff return Math.round(base * markerScale); }
Ta5d6fffunction applyMarkerSize() Ta5d6ff{
Ta5d6ff let styleEl = document.getElementById(Ta5d6ff'Ta5d6ffsize-styleTa5d6ff'Ta5d6ff);
Ta5d6ff if (!styleEl) Ta5d6ff{Ta5d6ff styleEl = document.createElement(Ta5d6ff'Ta5d6ffstyleTa5d6ff'Ta5d6ff); styleEl.id = Ta5d6ff'Ta5d6ffsize-styleTa5d6ff'Ta5d6ff; document.head.appendChild(styleEl); }
Ta5d6ff styleEl.textContent = `.node-label Ta5d6ff{Ta5d6ff font-size: $Ta5d6ff{Ta5d6ff_sr(7)}px !important; }`;
Ta5d6ff}
Ta5d6ffconst sizeSel = document.getElementById(Ta5d6ff'Ta5d6ffsize-selectTa5d6ff'Ta5d6ff);
Ta5d6ffsizeSel.value = localStorage.getItem(Ta5d6ff'Ta5d6ffmarker-sizeTa5d6ff'Ta5d6ff) || Ta5d6ff'Ta5d6ffsmallTa5d6ff'Ta5d6ff;
Ta5d6ffsizeSel.addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff markerScale = MARKER_SCALES[sizeSel.value];
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffmarker-sizeTa5d6ff'Ta5d6ff, sizeSel.value);
Ta5d6ff applyMarkerSize();
Ta5d6ff renderDiscovery();
Ta5d6ff});
Ta5d6ffapplyMarkerSize();
Ta5d6ff// ---- Infrastructure discovery layer ----
Ta5d6ffconst TYPE_COLORS = Ta5d6ff{Ta5d6ff RNode: Ta5d6ff'Ta5d6ff#f80Ta5d6ff'Ta5d6ff, Backbone: Ta5d6ff'Ta5d6ff#48fTa5d6ff'Ta5d6ff, I2P: Ta5d6ff'Ta5d6ff#ad4Ta5d6ff'Ta5d6ff, HaLow: Ta5d6ff'Ta5d6ff#0cfTa5d6ff'Ta5d6ff };
Ta5d6ffconst rnodeLayer = L.layerGroup().addTo(map);
Ta5d6ffconst backboneLayer = L.layerGroup().addTo(map);
Ta5d6ffconst i2pLayer = L.layerGroup().addTo(map);
Ta5d6ffconst halowLayer = L.layerGroup().addTo(map);
Ta5d6ffconst stackLayer = L.layerGroup().addTo(map);
Ta5d6ffconst TYPE_LAYERS = Ta5d6ff{Ta5d6ff RNode: rnodeLayer, Backbone: backboneLayer, I2P: i2pLayer, HaLow: halowLayer };
Ta5d6fffunction fmtUptime(s) Ta5d6ff{
Ta5d6ff if (!s) return Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (s < 3600) return Math.floor(s / 60) + Ta5d6ff'Ta5d6ffmTa5d6ff'Ta5d6ff;
Ta5d6ff if (s < 86400) return Math.floor(s / 3600) + Ta5d6ff'Ta5d6ffhTa5d6ff'Ta5d6ff;
Ta5d6ff return Math.floor(s / 86400) + Ta5d6ff'Ta5d6ffd Ta5d6ff'Ta5d6ff + Math.floor((s Ta5d6ff%Ta5d6ff 86400) / 3600) + Ta5d6ff'Ta5d6ffhTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction fmtHz(hz) Ta5d6ff{
Ta5d6ff if (!hz) return Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff return (hz / 1e6).toFixed(3) + Ta5d6ff'Ta5d6ff MHzTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction fmtBw(hz) Ta5d6ff{
Ta5d6ff if (!hz) return Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff return hz >= 1000 ? (hz / 1000).toFixed(1) + Ta5d6ff'Ta5d6ff kHzTa5d6ff'Ta5d6ff : hz + Ta5d6ff'Ta5d6ff HzTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction fmtAge(ts) Ta5d6ff{
Ta5d6ff if (!ts) return Ta5d6ff'Ta5d6ffunknownTa5d6ff'Ta5d6ff;
Ta5d6ff const s = Math.floor(Date.now() / 1000 - ts);
Ta5d6ff if (s < 120) return s + Ta5d6ff'Ta5d6ffs agoTa5d6ff'Ta5d6ff;
Ta5d6ff if (s < 7200) return Math.floor(s / 60) + Ta5d6ff'Ta5d6ffm agoTa5d6ff'Ta5d6ff;
Ta5d6ff if (s < 172800) return Math.floor(s / 3600) + Ta5d6ff'Ta5d6ffh agoTa5d6ff'Ta5d6ff;
Ta5d6ff return Math.floor(s / 86400) + Ta5d6ff'Ta5d6ffd agoTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction buildPopupLines(n) Ta5d6ff{
Ta5d6ff const color = TYPE_COLORS[n.type] || Ta5d6ff'Ta5d6ff#888Ta5d6ff'Ta5d6ff;
Ta5d6ff const lines = [
Ta5d6ff `<b style=Ta5d6ff"Ta5d6ffcolor:$Tffd700{color}Ta5d6ff"Ta5d6ff>$Tffd700{n.name}Ta5d6ff</b>`,
Ta5d6ff `<span style=Ta5d6ff"Ta5d6ffcolor:#666Ta5d6ff"Ta5d6ff>$Tffd700{n.type}Ta5d6ff</span>`,
Ta5d6ff ];
Ta5d6ff if (n.last_heard) lines.push(`heard: $Ta5d6ff{Ta5d6fffmtAge(n.last_heard)}`);
Ta5d6ff if (n.frequency) lines.push(`freq: $Ta5d6ff{Ta5d6fffmtHz(n.frequency)}`);
Ta5d6ff if (n.bandwidth) Ta5d6ff{Ta5d6ff let l = `bw: $Ta5d6ff{Ta5d6fffmtBw(n.bandwidth)}`; if (n.sf) l += ` SF$Tffd700{n.sf}Ta5d6ff`; if (n.cr) l += ` CR$Tffd700{n.cr}Ta5d6ff`; lines.push(l); }
Ta5d6ff if (n.height && n.type !== Ta5d6ff'Ta5d6ffBackboneTa5d6ff'Ta5d6ff) lines.push(`alt: $Ta5d6ff{Ta5d6ffMath.round(n.height)}m`);
Ta5d6ff if (n.remote) lines.push(`remote: $Tffd700{n.remote}Ta5d6ff:$Tffd700{n.port}Ta5d6ff`);
Ta5d6ff if (n.b32) lines.push(`remote: <span style=Ta5d6ff"Ta5d6ffcolor:#666Ta5d6ff"Ta5d6ff>$Tffd700{n.b32}Ta5d6ff</span>`);
Ta5d6ff if (n.hash) lines.push(`hash: <span style=Ta5d6ff"Ta5d6ffcolor:#666Ta5d6ff"Ta5d6ff>$Tffd700{n.hash}Ta5d6ff</span>`);
Ta5d6ff if (n.freq_str) lines.push(`freq: $Tffd700{n.freq_str}Ta5d6ff`);
Ta5d6ff if (n.bw_str) lines.push(`bw: $Tffd700{n.bw_str}Ta5d6ff`);
Ta5d6ff if (n.fw) lines.push(`fw: $Tffd700{n.fw}Ta5d6ff`);
Ta5d6ff if (n.uptime) lines.push(`up: $Ta5d6ff{Ta5d6fffmtUptime(n.uptime)}`);
Ta5d6ff if (n.lxmf && n.lxmf !== Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff.repeat(32)) lines.push(`lxmf: <span style=Ta5d6ff"Ta5d6ffcolor:#666Ta5d6ff"Ta5d6ff>$Tffd700{n.lxmf}Ta5d6ff</span>`);
Ta5d6ff if (n.tx_p != null) lines.push(`tx: $Tffd700{n.tx_p}Ta5d6ff pkts rx: $Tffd700{n.rx_p}Ta5d6ff pkts`);
Ta5d6ff return lines;
Ta5d6ff}
Ta5d6fflet discoveryCache = [];
Ta5d6ff// --- Expanded stack state ---
Ta5d6ffconst expandedGroup = Ta5d6ff{Ta5d6ff nodes: null, markers: [], stackMarker: null };
Ta5d6ffconst EXPAND_SPREAD_PX = 22;
Ta5d6ffconst EXPAND_MS = 220;
Ta5d6ffconst COLLAPSE_MS = 160;
Ta5d6fflet _animFrame = null;
Ta5d6fffunction _ease(t) Ta5d6ff{Ta5d6ff return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
Ta5d6fffunction _animateMarkers(markers, startLLs, endLLs, duration, onComplete) Ta5d6ff{
Ta5d6ff if (_animFrame) Ta5d6ff{Ta5d6ff cancelAnimationFrame(_animFrame); _animFrame = null; }
Ta5d6ff const t0 = performance.now();
Ta5d6ff function frame(now) Ta5d6ff{
Ta5d6ff const t = Math.min(1, (now - t0) / duration);
Ta5d6ff const e = _ease(t);
Ta5d6ff markers.forEach((m, i) => Ta5d6ff{
Ta5d6ff m.setLatLng([
Ta5d6ff startLLs[i].lat + (endLLs[i].lat - startLLs[i].lat) * e,
Ta5d6ff startLLs[i].lng + (endLLs[i].lng - startLLs[i].lng) * e,
Ta5d6ff ]);
Ta5d6ff });
Ta5d6ff if (t < 1) Ta5d6ff{Ta5d6ff _animFrame = requestAnimationFrame(frame); }
Ta5d6ff else Ta5d6ff{Ta5d6ff _animFrame = null; if (onComplete) onComplete(); }
Ta5d6ff }
Ta5d6ff _animFrame = requestAnimationFrame(frame);
Ta5d6ff}
Ta5d6fffunction collapseGroup() Ta5d6ff{
Ta5d6ff if (!expandedGroup.nodes) return;
Ta5d6ff if (_animFrame) Ta5d6ff{Ta5d6ff cancelAnimationFrame(_animFrame); _animFrame = null; }
Ta5d6ff const centerLL = L.latLng(expandedGroup.nodes[0].lat, expandedGroup.nodes[0].lon);
Ta5d6ff const markers = expandedGroup.markers.slice();
Ta5d6ff const stackMarker = expandedGroup.stackMarker;
Ta5d6ff expandedGroup.nodes = null; expandedGroup.markers = []; expandedGroup.stackMarker = null;
Ta5d6ff markers.forEach(m => Ta5d6ff{Ta5d6ff if (m.getTooltip()) m.closeTooltip(); });
Ta5d6ff const startLLs = markers.map(m => m.getLatLng());
Ta5d6ff const endLLs = markers.map(() => centerLL);
Ta5d6ff _animateMarkers(markers, startLLs, endLLs, COLLAPSE_MS, () => Ta5d6ff{
Ta5d6ff markers.forEach(m => map.removeLayer(m));
Ta5d6ff if (stackMarker) Ta5d6ff{
Ta5d6ff stackMarker.setOpacity(1);
Ta5d6ff const _el = stackMarker.getElement();
Ta5d6ff if (_el) _el.style.pointerEvents = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (stackMarker.getTooltip() && document.getElementById(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff).checked) stackMarker.openTooltip();
Ta5d6ff }
Ta5d6ff });
Ta5d6ff}
Ta5d6fffunction positionExpandedMarkers() Ta5d6ff{
Ta5d6ff expandedGroup.markers.forEach(m => map.removeLayer(m));
Ta5d6ff expandedGroup.markers = [];
Ta5d6ff const nodes = expandedGroup.nodes;
Ta5d6ff const n = nodes.length;
Ta5d6ff const centerPt = map.latLngToContainerPoint([nodes[0].lat, nodes[0].lon]);
Ta5d6ff const centerLL = map.containerPointToLatLng(centerPt);
Ta5d6ff const targetLLs = nodes.map((_, i) => Ta5d6ff{
Ta5d6ff const offsetPx = (i - (n - 1) / 2) * EXPAND_SPREAD_PX;
Ta5d6ff return map.containerPointToLatLng(L.point(centerPt.x, centerPt.y + offsetPx));
Ta5d6ff });
Ta5d6ff nodes.forEach((node, i) => Ta5d6ff{
Ta5d6ff const color = TYPE_COLORS[node.type] || Ta5d6ff'Ta5d6ff#888Ta5d6ff'Ta5d6ff;
Ta5d6ff const stale = node.last_heard && (Date.now() / 1000 - node.last_heard) > 172800;
Ta5d6ff const marker = L.circleMarker(centerLL, Ta5d6ff{
Ta5d6ff radius: _sr(5), color, fillColor: color,
Ta5d6ff fillOpacity: stale ? 0.25 : 0.75,
Ta5d6ff opacity: stale ? 0.4 : 0.9,
Ta5d6ff weight: 1,
Ta5d6ff });
Ta5d6ff const labelClass = Ta5d6ff'Ta5d6ffnode-label node-label-Ta5d6ff'Ta5d6ff + node.type.toLowerCase();
Ta5d6ff marker.bindTooltip(node.name, Ta5d6ff{Ta5d6ff permanent: true, direction: Ta5d6ff'Ta5d6ffrightTa5d6ff'Ta5d6ff, className: labelClass, offset: [6, 0] });
Ta5d6ff const popup = `<div style=Ta5d6ff"Ta5d6fffont:11px monospace;padding:4px 6px;min-width:180pxTa5d6ff"Ta5d6ff>$Ta5d6ff{Ta5d6ffbuildPopupLines(node).join(Ta5d6ff'Ta5d6ff<br>Ta5d6ff'Ta5d6ff)}</div>`;
Ta5d6ff marker.bindPopup(popup, Ta5d6ff{Ta5d6ff className: Ta5d6ff'Ta5d6ffmap-popupTa5d6ff'Ta5d6ff, maxWidth: 420, offset: [0, -0.25] });
Ta5d6ff let _wasOpen = false;
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffmousedownTa5d6ff'Ta5d6ff, function() Ta5d6ff{Ta5d6ff _wasOpen = this.isPopupOpen(); });
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, function(e) Ta5d6ff{Ta5d6ff L.DomEvent.stopPropagation(e); if (_wasOpen) this.closePopup(); });
Ta5d6ff marker.addTo(map);
Ta5d6ff marker.closeTooltip();
Ta5d6ff expandedGroup.markers.push(marker);
Ta5d6ff });
Ta5d6ff const startLLs = expandedGroup.markers.map(() => centerLL);
Ta5d6ff _animateMarkers(expandedGroup.markers, startLLs, targetLLs, EXPAND_MS, () => Ta5d6ff{
Ta5d6ff expandedGroup.markers.forEach(m => m.openTooltip());
Ta5d6ff });
Ta5d6ff}
Ta5d6fffunction expandGroup(nodes, stackMarker) Ta5d6ff{
Ta5d6ff if (expandedGroup.nodes === nodes) Ta5d6ff{Ta5d6ff collapseGroup(); return; }
Ta5d6ff collapseGroup();
Ta5d6ff expandedGroup.nodes = nodes;
Ta5d6ff expandedGroup.stackMarker = stackMarker;
Ta5d6ff stackMarker.setOpacity(0.25);
Ta5d6ff if (stackMarker.getTooltip()) stackMarker.closeTooltip();
Ta5d6ff const _el = stackMarker.getElement();
Ta5d6ff if (_el) _el.style.pointerEvents = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff positionExpandedMarkers();
Ta5d6ff}
Ta5d6ffmap.on(Ta5d6ff'Ta5d6ffzoomendTa5d6ff'Ta5d6ff, () => Ta5d6ff{Ta5d6ff if (expandedGroup.nodes) positionExpandedMarkers(); else renderDiscovery(); });
Ta5d6ffmap.on(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => collapseGroup());
Ta5d6fffunction makeStackIcon(nodes) Ta5d6ff{
Ta5d6ff const D = _sr(10), STEP = _sr(4);
Ta5d6ff const totalW = D + (nodes.length - 1) * STEP;
Ta5d6ff const circles = nodes.map((n, i) => Ta5d6ff{
Ta5d6ff const color = TYPE_COLORS[n.type] || Ta5d6ff'Ta5d6ff#888Ta5d6ff'Ta5d6ff;
Ta5d6ff return `<div style=Ta5d6ff"Ta5d6ffposition:absolute;width:$Tffd700{D}Ta5d6ffpx;height:$Tffd700{D}Ta5d6ffpx;border-radius:50Ta5d6ff%Ta5d6ff;` +
Ta5d6ff `background:$Tffd700{color}Ta5d6ff;border:1px solid rgba(255,255,255,0.35);` +
Ta5d6ff `left:$Ta5d6ff{Ta5d6ffi * STEP}px;top:0;z-index:$Tffd700{i}Ta5d6ff"Ta5d6ff></div>`;
Ta5d6ff }).join(Ta5d6ff'Ta5d6ff'Ta5d6ff);
Ta5d6ff return L.divIcon(Ta5d6ff{
Ta5d6ff html: `<div style=Ta5d6ff"Ta5d6ffposition:relative;width:$Tffd700{totalW}Ta5d6ffpx;height:$Tffd700{D}Ta5d6ffpxTa5d6ff"Ta5d6ff>$Tffd700{circles}Ta5d6ff</div>`,
Ta5d6ff className: Ta5d6ff'Ta5d6ffnode-stackTa5d6ff'Ta5d6ff,
Ta5d6ff iconSize: [totalW, D],
Ta5d6ff iconAnchor: [Math.round(totalW / 2), Math.round(D / 2)],
Ta5d6ff });
Ta5d6ff}
Ta5d6fffunction addSingleMarker(n, layer) Ta5d6ff{
Ta5d6ff const color = TYPE_COLORS[n.type] || Ta5d6ff'Ta5d6ff#888Ta5d6ff'Ta5d6ff;
Ta5d6ff const stale = n.last_heard && (Date.now() / 1000 - n.last_heard) > 172800;
Ta5d6ff const marker = L.circleMarker([n.lat, n.lon], Ta5d6ff{
Ta5d6ff radius: _sr(5), color, fillColor: color,
Ta5d6ff fillOpacity: stale ? 0.25 : 0.75,
Ta5d6ff opacity: stale ? 0.4 : 0.9,
Ta5d6ff weight: 1,
Ta5d6ff });
Ta5d6ff const labelClass = Ta5d6ff'Ta5d6ffnode-label node-label-Ta5d6ff'Ta5d6ff + n.type.toLowerCase();
Ta5d6ff marker.bindTooltip(n.name, Ta5d6ff{Ta5d6ff permanent: true, direction: Ta5d6ff'Ta5d6fftopTa5d6ff'Ta5d6ff, className: labelClass, offset: [0, -4] });
Ta5d6ff if (!document.getElementById(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff).checked) marker.once(Ta5d6ff'Ta5d6ffaddTa5d6ff'Ta5d6ff, () => marker.closeTooltip());
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffpopupopenTa5d6ff'Ta5d6ff, () => marker.closeTooltip());
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffpopupcloseTa5d6ff'Ta5d6ff, () => Ta5d6ff{Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff).checked) marker.openTooltip(); });
Ta5d6ff const popup = `<div style=Ta5d6ff"Ta5d6fffont:11px monospace;padding:4px 6px;min-width:180pxTa5d6ff"Ta5d6ff>$Ta5d6ff{Ta5d6ffbuildPopupLines(n).join(Ta5d6ff'Ta5d6ff<br>Ta5d6ff'Ta5d6ff)}</div>`;
Ta5d6ff marker.bindPopup(popup, Ta5d6ff{Ta5d6ff className: Ta5d6ff'Ta5d6ffmap-popupTa5d6ff'Ta5d6ff, maxWidth: 420, offset: [0, -0.25] });
Ta5d6ff let _wasOpen = false;
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffmousedownTa5d6ff'Ta5d6ff, function() Ta5d6ff{Ta5d6ff _wasOpen = this.isPopupOpen(); });
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, function() Ta5d6ff{Ta5d6ff if (_wasOpen) this.closePopup(); });
Ta5d6ff layer.addLayer(marker);
Ta5d6ff}
Ta5d6fffunction renderDiscovery() Ta5d6ff{
Ta5d6ff collapseGroup();
Ta5d6ff Object.values(TYPE_LAYERS).forEach(l => l.clearLayers());
Ta5d6ff stackLayer.clearLayers();
Ta5d6ff const visibleTypes = new Set();
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-rnodeTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffRNodeTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-backboneTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffBackboneTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-i2pTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffI2PTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-halowTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffHaLowTa5d6ff'Ta5d6ff);
Ta5d6ff const filtered = discoveryCache.filter(n => visibleTypes.has(n.type) && _geoFilterMatches(n));
Ta5d6ff const threshold = _sr(10);
Ta5d6ff const clusters = [];
Ta5d6ff filtered.forEach(n => Ta5d6ff{
Ta5d6ff const pt = map.latLngToContainerPoint([n.lat, n.lon]);
Ta5d6ff const hit = clusters.find(c => Ta5d6ff{
Ta5d6ff const cp = map.latLngToContainerPoint([c[0].lat, c[0].lon]);
Ta5d6ff return Math.hypot(pt.x - cp.x, pt.y - cp.y) < threshold;
Ta5d6ff });
Ta5d6ff if (hit) hit.push(n);
Ta5d6ff else clusters.push([n]);
Ta5d6ff });
Ta5d6ff clusters.forEach(nodes => Ta5d6ff{
Ta5d6ff if (nodes.length === 1) Ta5d6ff{
Ta5d6ff addSingleMarker(nodes[0], TYPE_LAYERS[nodes[0].type]);
Ta5d6ff } else Ta5d6ff{
Ta5d6ff const marker = L.marker([nodes[0].lat, nodes[0].lon], Ta5d6ff{Ta5d6ff icon: makeStackIcon(nodes) });
Ta5d6ff const labelNode = nodes.find(n => n.type === Ta5d6ff'Ta5d6ffBackboneTa5d6ff'Ta5d6ff) || nodes.find(n => n.type === Ta5d6ff'Ta5d6ffHaLowTa5d6ff'Ta5d6ff) || nodes[0];
Ta5d6ff const labelClass = Ta5d6ff'Ta5d6ffnode-label node-label-Ta5d6ff'Ta5d6ff + labelNode.type.toLowerCase();
Ta5d6ff marker.bindTooltip(labelNode.name, Ta5d6ff{Ta5d6ff permanent: true, direction: Ta5d6ff'Ta5d6fftopTa5d6ff'Ta5d6ff, className: labelClass, offset: [0, -4] });
Ta5d6ff if (!document.getElementById(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff).checked) marker.once(Ta5d6ff'Ta5d6ffaddTa5d6ff'Ta5d6ff, () => marker.closeTooltip());
Ta5d6ff marker.on(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, e => Ta5d6ff{Ta5d6ff L.DomEvent.stopPropagation(e); if (expandedGroup.nodes !== nodes) expandGroup(nodes, marker); });
Ta5d6ff stackLayer.addLayer(marker);
Ta5d6ff }
Ta5d6ff });
Ta5d6ff}
Ta5d6fffunction renderNodesList() Ta5d6ff{
Ta5d6ff const list = document.getElementById(Ta5d6ff'Ta5d6ffnodes-listTa5d6ff'Ta5d6ff);
Ta5d6ff if (!list || document.getElementById(Ta5d6ff'Ta5d6ffnodes-panelTa5d6ff'Ta5d6ff).style.display === Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff) return;
Ta5d6ff const visibleTypes = new Set();
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-rnodeTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffRNodeTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-backboneTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffBackboneTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-i2pTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffI2PTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-halowTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffHaLowTa5d6ff'Ta5d6ff);
Ta5d6ff const query = document.getElementById(Ta5d6ff'Ta5d6ffnodes-searchTa5d6ff'Ta5d6ff).value.toLowerCase().trim();
Ta5d6ff const geoC = document.getElementById(Ta5d6ff'Ta5d6ffgeo-countryTa5d6ff'Ta5d6ff).value;
Ta5d6ff const geoS = document.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff).value;
Ta5d6ff const geoCi = document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).value;
Ta5d6ff const nodes = discoveryCache.filter(n =>
Ta5d6ff visibleTypes.has(n.type) &&
Ta5d6ff (!query || n.name.toLowerCase().includes(query)) &&
Ta5d6ff (!geoC || n.country === geoC) &&
Ta5d6ff (!geoS || n.state === geoS) &&
Ta5d6ff (!geoCi || n.city === geoCi)
Ta5d6ff );
Ta5d6ff list.innerHTML = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (nodes.length === 0) Ta5d6ff{
Ta5d6ff list.innerHTML = Ta5d6ff'Ta5d6ff<div style=Ta5d6ff"Ta5d6ffpadding:12px;font-size:11px;color:var(--text-muted)Ta5d6ff"Ta5d6ff>No nodes</div>Ta5d6ff'Ta5d6ff;
Ta5d6ff return;
Ta5d6ff }
Ta5d6ff nodes.forEach(n => Ta5d6ff{
Ta5d6ff const color = TYPE_COLORS[n.type] || Ta5d6ff'Ta5d6ff#888Ta5d6ff'Ta5d6ff;
Ta5d6ff const div = document.createElement(Ta5d6ff'Ta5d6ffdivTa5d6ff'Ta5d6ff);
Ta5d6ff div.className = Ta5d6ff'Ta5d6ffnode-entryTa5d6ff'Ta5d6ff;
Ta5d6ff const parts = [];
Ta5d6ff if (n.frequency) parts.push(fmtHz(n.frequency));
Ta5d6ff if (n.bandwidth) Ta5d6ff{Ta5d6ff let s = fmtBw(n.bandwidth); if (n.sf) s += ` SF$Tffd700{n.sf}Ta5d6ff`; if (n.cr) s += ` CR$Tffd700{n.cr}Ta5d6ff`; parts.push(s); }
Ta5d6ff if (n.remote) parts.push(`$Tffd700{n.remote}Ta5d6ff:$Tffd700{n.port}Ta5d6ff`);
Ta5d6ff if (n.b32) parts.push(n.b32.length > 24 ? n.b32.substring(0, 22) + Ta5d6ff'Ta5d6ff…Ta5d6ff'Ta5d6ff : n.b32);
Ta5d6ff if (n.freq_str) parts.push(n.freq_str);
Ta5d6ff if (n.bw_str) parts.push(n.bw_str);
Ta5d6ff if (n.fw) parts.push(`fw $Tffd700{n.fw}Ta5d6ff`);
Ta5d6ff const age = n.last_heard ? fmtAge(n.last_heard) : Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff const dot = `<span style=Ta5d6ff"Ta5d6ffwidth:8px;height:8px;border-radius:50Ta5d6ff%Ta5d6ff;background:$Tffd700{color}Ta5d6ff;flex-shrink:0;display:inline-blockTa5d6ff"Ta5d6ff></span>`;
Ta5d6ff div.innerHTML =
Ta5d6ff `<div class=Ta5d6ff"Ta5d6ffnode-entry-nameTa5d6ff"Ta5d6ff><span class=Ta5d6ff"Ta5d6ffnode-entry-name-leftTa5d6ff"Ta5d6ff>$Tffd700{dot}Ta5d6ff<span>$Tffd700{n.name}Ta5d6ff</span></span><span class=Ta5d6ff"Ta5d6ffnode-entry-typeTa5d6ff"Ta5d6ff>$Tffd700{n.type}Ta5d6ff</span></div>` +
Ta5d6ff (parts.length ? `<div class=Ta5d6ff"Ta5d6ffnode-entry-metaTa5d6ff"Ta5d6ff>$Ta5d6ff{Ta5d6ffparts.join(Ta5d6ff'Ta5d6ff Ta5d6ff'Ta5d6ff)}</div>` : Ta5d6ff'Ta5d6ff'Ta5d6ff) +
Ta5d6ff `<div class=Ta5d6ff"Ta5d6ffnode-entry-bottomTa5d6ff"Ta5d6ff><span class=Ta5d6ff"Ta5d6ffnode-entry-coordsTa5d6ff"Ta5d6ff>$Ta5d6ff{Ta5d6ffn.lat.toFixed(5)}, $Ta5d6ff{Ta5d6ffn.lon.toFixed(5)}</span>$Ta5d6ff{Ta5d6ffage ? `<span class=Ta5d6ff"Ta5d6ffnode-entry-ageTa5d6ff"Ta5d6ff>$Tffd700{age}Ta5d6ff</span>` : Ta5d6ff'Ta5d6ff'Ta5d6ff}</div>`;
Ta5d6ff div.addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => map.setView([n.lat, n.lon], Math.max(map.getZoom(), 15)));
Ta5d6ff list.appendChild(div);
Ta5d6ff });
Ta5d6ff}
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffnodes-searchTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffinputTa5d6ff'Ta5d6ff, renderNodesList);
Ta5d6fflet _geoTimer = null;
Ta5d6fffunction _clientGeoKey(lat, lon) Ta5d6ff{
Ta5d6ff return (Math.round(lat * 100) / 100).toFixed(2) + Ta5d6ff'Ta5d6ff,Ta5d6ff'Ta5d6ff + (Math.round(lon * 100) / 100).toFixed(2);
Ta5d6ff}
Ta5d6fffunction _setSelectOptions(sel, values, placeholder) Ta5d6ff{
Ta5d6ff const current = sel.value;
Ta5d6ff sel.innerHTML = `<option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>$Tffd700{placeholder}Ta5d6ff</option>`;
Ta5d6ff values.forEach(v => Ta5d6ff{
Ta5d6ff const opt = document.createElement(Ta5d6ff'Ta5d6ffoptionTa5d6ff'Ta5d6ff);
Ta5d6ff opt.value = v; opt.textContent = v;
Ta5d6ff if (v === current) opt.selected = true;
Ta5d6ff sel.appendChild(opt);
Ta5d6ff });
Ta5d6ff if (current && !values.includes(current)) sel.value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction _geoFilterMatches(n) Ta5d6ff{
Ta5d6ff const c = document.getElementById(Ta5d6ff'Ta5d6ffgeo-countryTa5d6ff'Ta5d6ff).value;
Ta5d6ff const s = document.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff).value;
Ta5d6ff const ci = document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).value;
Ta5d6ff return (!c || n.country === c) &&
Ta5d6ff (!s || n.state === s) &&
Ta5d6ff (!ci || n.city === ci);
Ta5d6ff}
Ta5d6fffunction buildGeoDropdowns() Ta5d6ff{
Ta5d6ff const visibleTypes = new Set();
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-rnodeTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffRNodeTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-backboneTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffBackboneTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-i2pTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffI2PTa5d6ff'Ta5d6ff);
Ta5d6ff if (document.getElementById(Ta5d6ff'Ta5d6ffcheck-halowTa5d6ff'Ta5d6ff).checked) visibleTypes.add(Ta5d6ff'Ta5d6ffHaLowTa5d6ff'Ta5d6ff);
Ta5d6ff const all = discoveryCache.filter(n => visibleTypes.has(n.type));
Ta5d6ff const selC = document.getElementById(Ta5d6ff'Ta5d6ffgeo-countryTa5d6ff'Ta5d6ff);
Ta5d6ff const selS = document.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff);
Ta5d6ff const selCi = document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff);
Ta5d6ff if (all.length > 0 && all.every(n => !n.country)) Ta5d6ff{
Ta5d6ff selC.innerHTML = Ta5d6ff'Ta5d6ff<option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>Geocoding…</option>Ta5d6ff'Ta5d6ff;
Ta5d6ff selS.innerHTML = Ta5d6ff'Ta5d6ff<option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>—</option>Ta5d6ff'Ta5d6ff;
Ta5d6ff selCi.innerHTML = Ta5d6ff'Ta5d6ff<option value=Ta5d6ff"Ta5d6ff"Ta5d6ff>—</option>Ta5d6ff'Ta5d6ff;
Ta5d6ff return;
Ta5d6ff }
Ta5d6ff const countries = [...new Set(all.filter(n => n.country).map(n => n.country))].sort();
Ta5d6ff _setSelectOptions(selC, countries, Ta5d6ff'Ta5d6ffAll CountriesTa5d6ff'Ta5d6ff);
Ta5d6ff if (!selC.value) Ta5d6ff{
Ta5d6ff const def = localStorage.getItem(Ta5d6ff'Ta5d6ffgeo-filter-countryTa5d6ff'Ta5d6ff) ?? DEFAULT_COUNTRY;
Ta5d6ff if (countries.includes(def)) selC.value = def;
Ta5d6ff }
Ta5d6ff const byCountry = selC.value ? all.filter(n => n.country === selC.value) : all;
Ta5d6ff const states = [...new Set(byCountry.filter(n => n.state).map(n => n.state))].sort();
Ta5d6ff _setSelectOptions(selS, states, Ta5d6ff'Ta5d6ffAll RegionsTa5d6ff'Ta5d6ff);
Ta5d6ff if (!selS.value) Ta5d6ff{
Ta5d6ff const def = localStorage.getItem(Ta5d6ff'Ta5d6ffgeo-filter-stateTa5d6ff'Ta5d6ff) ?? Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (states.includes(def)) selS.value = def;
Ta5d6ff }
Ta5d6ff const byState = selS.value ? byCountry.filter(n => n.state === selS.value) : byCountry;
Ta5d6ff const cities = [...new Set(byState.filter(n => n.city).map(n => n.city))].sort();
Ta5d6ff _setSelectOptions(selCi, cities, Ta5d6ff'Ta5d6ffAll CitiesTa5d6ff'Ta5d6ff);
Ta5d6ff if (!selCi.value) Ta5d6ff{
Ta5d6ff const def = localStorage.getItem(Ta5d6ff'Ta5d6ffgeo-filter-cityTa5d6ff'Ta5d6ff) ?? Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff if (cities.includes(def)) selCi.value = def;
Ta5d6ff }
Ta5d6ff}
Ta5d6fffunction updateGeoData() Ta5d6ff{
Ta5d6ff fetch(Ta5d6ff'Ta5d6ff/api/geoTa5d6ff'Ta5d6ff).then(r => r.json()).then(cache => Ta5d6ff{
Ta5d6ff let changed = false;
Ta5d6ff discoveryCache.forEach(n => Ta5d6ff{
Ta5d6ff if (n.country) return;
Ta5d6ff const g = cache[_clientGeoKey(n.lat, n.lon)];
Ta5d6ff if (g && (g.country || g.city)) Ta5d6ff{
Ta5d6ff n.country = g.country; n.state = g.state; n.city = g.city;
Ta5d6ff changed = true;
Ta5d6ff }
Ta5d6ff });
Ta5d6ff if (changed) Ta5d6ff{Ta5d6ff buildGeoDropdowns(); renderNodesList(); }
Ta5d6ff if (!discoveryCache.some(n => !n.country)) Ta5d6ff{Ta5d6ff clearInterval(_geoTimer); _geoTimer = null; }
Ta5d6ff }).catch(() => Tffd700{}Ta5d6ff);
Ta5d6ff}
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffgeo-countryTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffgeo-filter-countryTa5d6ff'Ta5d6ff, document.getElementById(Ta5d6ff'Ta5d6ffgeo-countryTa5d6ff'Ta5d6ff).value);
Ta5d6ff localStorage.removeItem(Ta5d6ff'Ta5d6ffgeo-filter-stateTa5d6ff'Ta5d6ff);
Ta5d6ff localStorage.removeItem(Ta5d6ff'Ta5d6ffgeo-filter-cityTa5d6ff'Ta5d6ff);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff).value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff buildGeoDropdowns();
Ta5d6ff renderDiscovery();
Ta5d6ff renderNodesList();
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffgeo-filter-stateTa5d6ff'Ta5d6ff, document.getElementById(Ta5d6ff'Ta5d6ffgeo-stateTa5d6ff'Ta5d6ff).value);
Ta5d6ff localStorage.removeItem(Ta5d6ff'Ta5d6ffgeo-filter-cityTa5d6ff'Ta5d6ff);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).value = Ta5d6ff'Ta5d6ff'Ta5d6ff;
Ta5d6ff buildGeoDropdowns();
Ta5d6ff renderDiscovery();
Ta5d6ff renderNodesList();
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffgeo-filter-cityTa5d6ff'Ta5d6ff, document.getElementById(Ta5d6ff'Ta5d6ffgeo-cityTa5d6ff'Ta5d6ff).value);
Ta5d6ff renderDiscovery();
Ta5d6ff renderNodesList();
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffnodes-closeTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-panelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff;
Ta5d6ff if (_geoTimer) Ta5d6ff{Ta5d6ff clearInterval(_geoTimer); _geoTimer = null; }
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffnodes-openTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-panelTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffflexTa5d6ff'Ta5d6ff;
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffnodes-open-wrapTa5d6ff'Ta5d6ff).style.display = Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff buildGeoDropdowns();
Ta5d6ff renderNodesList();
Ta5d6ff if (discoveryCache.some(n => !n.country)) Ta5d6ff{
Ta5d6ff updateGeoData();
Ta5d6ff _geoTimer = setInterval(updateGeoData, 5000);
Ta5d6ff }
Ta5d6ff});
Ta5d6fffunction setDiscoveryWarning(show) Ta5d6ff{
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6ffdiscovery-warningTa5d6ff'Ta5d6ff).style.display = show ? Ta5d6ff'Ta5d6ffblockTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6fffunction loadDiscovered() Ta5d6ff{
Ta5d6ff fetch(Ta5d6ff'Ta5d6ff/api/discoveredTa5d6ff'Ta5d6ff).then(r => Ta5d6ff{
Ta5d6ff setDiscoveryWarning(r.headers.get(Ta5d6ff'Ta5d6ffX-Rnstatus-OKTa5d6ff'Ta5d6ff) === Ta5d6ff'Ta5d6ff0Ta5d6ff'Ta5d6ff);
Ta5d6ff return r.json();
Ta5d6ff }).then(nodes => Ta5d6ff{
Ta5d6ff discoveryCache = nodes;
Ta5d6ff buildGeoDropdowns();
Ta5d6ff renderDiscovery();
Ta5d6ff renderNodesList();
Ta5d6ff }).catch(() => Ta5d6ff{Ta5d6ff setDiscoveryWarning(true); });
Ta5d6ff}
Ta5d6ffconst LAYER_CHECKS = Ta5d6ff{
Ta5d6ff Ta5d6ff'Ta5d6ffcheck-rnodeTa5d6ff'Ta5d6ff: Ta5d6ff{Ta5d6ff dot: Ta5d6ff'Ta5d6fftab-dot-rnodeTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff'Ta5d6ffcheck-backboneTa5d6ff'Ta5d6ff: Ta5d6ff{Ta5d6ff dot: Ta5d6ff'Ta5d6fftab-dot-backboneTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff'Ta5d6ffcheck-i2pTa5d6ff'Ta5d6ff: Ta5d6ff{Ta5d6ff dot: Ta5d6ff'Ta5d6fftab-dot-i2pTa5d6ff'Ta5d6ff },
Ta5d6ff Ta5d6ff'Ta5d6ffcheck-halowTa5d6ff'Ta5d6ff: Ta5d6ff{Ta5d6ff dot: Ta5d6ff'Ta5d6fftab-dot-halowTa5d6ff'Ta5d6ff },
Ta5d6ff};
Ta5d6fffunction syncTabDot(id, checked) Ta5d6ff{
Ta5d6ff const dot = document.getElementById(LAYER_CHECKS[id].dot);
Ta5d6ff if (dot) dot.style.display = checked ? Ta5d6ff'Ta5d6ffinline-blockTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffnoneTa5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6ff// Restore persisted checkbox states before init
Ta5d6ff[...Object.keys(LAYER_CHECKS), Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff].forEach(id => Ta5d6ff{
Ta5d6ff const saved = localStorage.getItem(id);
Ta5d6ff if (saved !== null) document.getElementById(id).checked = saved === Ta5d6ff'Ta5d6fftrueTa5d6ff'Ta5d6ff;
Ta5d6ff});
Ta5d6ffObject.entries(LAYER_CHECKS).forEach(([id]) => Ta5d6ff{
Ta5d6ff syncTabDot(id, document.getElementById(id).checked);
Ta5d6ff document.getElementById(id).addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, e => Ta5d6ff{
Ta5d6ff localStorage.setItem(id, e.target.checked);
Ta5d6ff syncTabDot(id, e.target.checked);
Ta5d6ff renderDiscovery();
Ta5d6ff renderNodesList();
Ta5d6ff buildGeoDropdowns();
Ta5d6ff });
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, e => Ta5d6ff{
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffcheck-labelsTa5d6ff'Ta5d6ff, e.target.checked);
Ta5d6ff [rnodeLayer, backboneLayer, i2pLayer, halowLayer, stackLayer].forEach(layer =>
Ta5d6ff layer.eachLayer(m => Ta5d6ff{Ta5d6ff if (m.getTooltip()) e.target.checked ? m.openTooltip() : m.closeTooltip(); }));
Ta5d6ff if (expandedGroup.nodes) positionExpandedMarkers();
Ta5d6ff});
Ta5d6ffloadDiscovered();
Ta5d6ffsetInterval(loadDiscovered, 300000); // refresh every 5 minutes
Ta5d6ff// ---- Theme toggle ----
Ta5d6fffunction applyTheme(light) Ta5d6ff{
Ta5d6ff document.body.classList.toggle(Ta5d6ff'Ta5d6fflight-themeTa5d6ff'Ta5d6ff, light);
Ta5d6ff document.getElementById(Ta5d6ff'Ta5d6fftheme-toggleTa5d6ff'Ta5d6ff).textContent = light ? Ta5d6ff'Ta5d6ff☽Ta5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ff☀Ta5d6ff'Ta5d6ff;
Ta5d6ff}
Ta5d6ffapplyTheme(localStorage.getItem(Ta5d6ff'Ta5d6ffthemeTa5d6ff'Ta5d6ff) === Ta5d6ff'Ta5d6fflightTa5d6ff'Ta5d6ff);
Ta5d6ffwindow.addEventListener(Ta5d6ff'Ta5d6ffmessageTa5d6ff'Ta5d6ff, function(e) Ta5d6ff{
Ta5d6ff if (e.origin !== Ta5d6ff'Ta5d6ffhttps://rns.moscowTa5d6ff'Ta5d6ff) return;
Ta5d6ff if (e.data === Ta5d6ff'Ta5d6fflightTa5d6ff'Ta5d6ff || e.data === Ta5d6ff'Ta5d6ffdarkTa5d6ff'Ta5d6ff) Ta5d6ff{
Ta5d6ff const newLight = e.data === Ta5d6ff'Ta5d6fflightTa5d6ff'Ta5d6ff;
Ta5d6ff applyTheme(newLight);
Ta5d6ff const idx = newLight ? 2 : 0;
Ta5d6ff const sel = document.getElementById(Ta5d6ff'Ta5d6fftileset-selectTa5d6ff'Ta5d6ff);
Ta5d6ff if (sel) sel.value = idx;
Ta5d6ff tileLayer.setUrl(TILESETS[idx].url);
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6fftilesetTa5d6ff'Ta5d6ff, idx);
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffthemeTa5d6ff'Ta5d6ff, e.data);
Ta5d6ff }
Ta5d6ff});
Ta5d6ffdocument.getElementById(Ta5d6ff'Ta5d6fftheme-toggleTa5d6ff'Ta5d6ff).addEventListener(Ta5d6ff'Ta5d6ffclickTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff const isLight = document.body.classList.contains(Ta5d6ff'Ta5d6fflight-themeTa5d6ff'Ta5d6ff);
Ta5d6ff const newLight = !isLight;
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffthemeTa5d6ff'Ta5d6ff, newLight ? Ta5d6ff'Ta5d6fflightTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffdarkTa5d6ff'Ta5d6ff);
Ta5d6ff applyTheme(newLight);
Ta5d6ff const idx = newLight ? 2 : 0;
Ta5d6ff const sel = document.getElementById(Ta5d6ff'Ta5d6fftileset-selectTa5d6ff'Ta5d6ff);
Ta5d6ff sel.value = idx;
Ta5d6ff tileLayer.setUrl(TILESETS[idx].url);
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6fftilesetTa5d6ff'Ta5d6ff, idx);
Ta5d6ff});
Ta5d6ff// ---- Tileset selector ----
Ta5d6ffconst tilesetSel = document.getElementById(Ta5d6ff'Ta5d6fftileset-selectTa5d6ff'Ta5d6ff);
Ta5d6ffTILESETS.forEach((t, i) => Ta5d6ff{
Ta5d6ff const opt = document.createElement(Ta5d6ff'Ta5d6ffoptionTa5d6ff'Ta5d6ff);
Ta5d6ff opt.value = i;
Ta5d6ff opt.textContent = t.label;
Ta5d6ff tilesetSel.appendChild(opt);
Ta5d6ff});
Ta5d6ffconst savedTileset = localStorage.getItem(Ta5d6ff'Ta5d6fftilesetTa5d6ff'Ta5d6ff);
Ta5d6ffif (savedTileset !== null) Ta5d6ff{
Ta5d6ff const idx = parseInt(savedTileset);
Ta5d6ff if (idx >= 0 && idx < TILESETS.length) Ta5d6ff{
Ta5d6ff tilesetSel.value = idx;
Ta5d6ff tileLayer.setUrl(TILESETS[idx].url);
Ta5d6ff }
Ta5d6ff}
Ta5d6fftilesetSel.addEventListener(Ta5d6ff'Ta5d6ffchangeTa5d6ff'Ta5d6ff, () => Ta5d6ff{
Ta5d6ff const idx = parseInt(tilesetSel.value);
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6fftilesetTa5d6ff'Ta5d6ff, idx);
Ta5d6ff tileLayer.setUrl(TILESETS[idx].url);
Ta5d6ff const newLight = idx !== 0;
Ta5d6ff localStorage.setItem(Ta5d6ff'Ta5d6ffthemeTa5d6ff'Ta5d6ff, newLight ? Ta5d6ff'Ta5d6fflightTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffdarkTa5d6ff'Ta5d6ff);
Ta5d6ff applyTheme(newLight);
Ta5d6ff});
Ta5d6ff</script>
Ta5d6ff</body>
Ta5d6ff</html>Ta5d6ff"""
Tff7b72def Td2a8ffget_dbTb4b4b4(Tb4b4b4)Tb4b4b4:
Te6edf3con Tff7b72= Te6edf3sqlite3Tff7b72.Td2a8ffconnectTb4b4b4(Te6edf3DB_PATHTb4b4b4)
Te6edf3conTff7b72.Td2a8ffrow_factory Tff7b72= Te6edf3sqlite3Tff7b72.Td2a8ffRow
Tff7b72return Te6edf3con
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/Ta5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffindexTb4b4b4(Tb4b4b4)Tb4b4b4:
Te6edf3has_initial Tff7b72= Tffa657allTb4b4b4(Te6edf3x Tff7b72is Tff7b72not Tff7b72None Tff7b72for Te6edf3x Tff7b72in Tb4b4b4[Te6edf3INITIAL_LATTb4b4b4, Te6edf3INITIAL_LONTb4b4b4, Te6edf3INITIAL_ZOOMTb4b4b4]Tb4b4b4)
Tff7b72return Te6edf3render_template_stringTb4b4b4(Te6edf3MAP_HTMLTb4b4b4,
Te6edf3page_title Tff7b72= Te6edf3PAGE_TITLETb4b4b4,
Te6edf3init_lat Tff7b72= Te6edf3INITIAL_LAT Tff7b72if Te6edf3has_initial Tff7b72else T79c0ff0Tb4b4b4,
Te6edf3init_lon Tff7b72= Te6edf3INITIAL_LON Tff7b72if Te6edf3has_initial Tff7b72else T79c0ff0Tb4b4b4,
Te6edf3init_zoom Tff7b72= Te6edf3INITIAL_ZOOM Tff7b72if Te6edf3has_initial Tff7b72else T79c0ff2Tb4b4b4,
Te6edf3fit_done Tff7b72= Ta5d6ff'Ta5d6fftrueTa5d6ff' Tff7b72if Te6edf3has_initial Tff7b72else Ta5d6ff'Ta5d6fffalseTa5d6ff'Tb4b4b4,
Te6edf3default_country Tff7b72= Te6edf3jsonTff7b72.Td2a8ffdumpsTb4b4b4(Te6edf3DEFAULT_COUNTRYTb4b4b4)Tb4b4b4)
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/rangeTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_rangeTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3con Tff7b72= Te6edf3get_dbTb4b4b4(Tb4b4b4)
Te6edf3row Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(Ta5d6ff"Ta5d6ffSELECT MIN(ts) as min_ts, MAX(ts) as max_ts FROM pointsTa5d6ff"Tb4b4b4)Tff7b72.Td2a8fffetchoneTb4b4b4(Tb4b4b4)
Te6edf3conTff7b72.Td2a8ffcloseTb4b4b4(Tb4b4b4)
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6ffmin_tsTa5d6ff"Tb4b4b4: Te6edf3rowTb4b4b4[Ta5d6ff"Ta5d6ffmin_tsTa5d6ff"Tb4b4b4]Tb4b4b4, Ta5d6ff"Ta5d6ffmax_tsTa5d6ff"Tb4b4b4: Te6edf3rowTb4b4b4[Ta5d6ff"Ta5d6ffmax_tsTa5d6ff"Tb4b4b4]Tb4b4b4}Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6fferrorTa5d6ff"Tb4b4b4: Tffa657strTb4b4b4(Te6edf3eTb4b4b4)Tb4b4b4}Tb4b4b4)Tb4b4b4, T79c0ff500
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/nodesTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_nodesTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3con Tff7b72= Te6edf3get_dbTb4b4b4(Tb4b4b4)
Te6edf3rows Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(Ta5d6ff"Ta5d6ffSELECT DISTINCT sender_hash FROM pointsTa5d6ff"Tb4b4b4)Tff7b72.Td2a8fffetchallTb4b4b4(Tb4b4b4)
Te6edf3conTff7b72.Td2a8ffcloseTb4b4b4(Tb4b4b4)
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4[Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffsender_hashTa5d6ff"Tb4b4b4]Tff7b72.Td2a8ffhexTb4b4b4(Tb4b4b4) Tff7b72for Te6edf3r Tff7b72in Te6edf3rowsTb4b4b4]Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6fferrorTa5d6ff"Tb4b4b4: Tffa657strTb4b4b4(Te6edf3eTb4b4b4)Tb4b4b4}Tb4b4b4)Tb4b4b4, T79c0ff500
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/pointsTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_pointsTb4b4b4(Tb4b4b4)Tb4b4b4:
Te6edf3since Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffsinceTa5d6ff"Tb4b4b4, T79c0ff0Tb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Te6edf3from_ts Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fffrom_tsTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Te6edf3to_ts Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffto_tsTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Tff7b72tryTb4b4b4:
Te6edf3con Tff7b72= Te6edf3get_dbTb4b4b4(Tb4b4b4)
Tff7b72if Te6edf3from_ts Tff7b72is Tff7b72not Tff7b72NoneTb4b4b4:
Te6edf3rows Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(
Ta5d6ff"Ta5d6ffSELECT lat, lon, rssi, snr, dl_rssi, dl_snr, q, ts, sender_hash, source FROM points WHERE ts >= ? AND ts <= ? ORDER BY tsTa5d6ff"Tb4b4b4,
Tb4b4b4(Te6edf3from_tsTb4b4b4, Te6edf3to_ts Tff7b72if Te6edf3to_ts Tff7b72is Tff7b72not Tff7b72None Tff7b72else Tffa657intTb4b4b4(Te6edf3timeTff7b72.Td2a8fftimeTb4b4b4(Tb4b4b4)Tb4b4b4)Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tff7b72.Td2a8fffetchallTb4b4b4(Tb4b4b4)
Tff7b72elseTb4b4b4:
Te6edf3rows Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(
Ta5d6ff"Ta5d6ffSELECT lat, lon, rssi, snr, dl_rssi, dl_snr, q, ts, sender_hash, source FROM points WHERE ts > ? ORDER BY tsTa5d6ff"Tb4b4b4,
Tb4b4b4(Te6edf3sinceTb4b4b4,Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tff7b72.Td2a8fffetchallTb4b4b4(Tb4b4b4)
Te6edf3conTff7b72.Td2a8ffcloseTb4b4b4(Tb4b4b4)
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4[Tb4b4b4{
Tff7b72*Tff7b72*Tffa657dictTb4b4b4(Te6edf3rTb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4] Tff7b72/ T79c0ff1_000_000Tb4b4b4,
Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4] Tff7b72/ T79c0ff1_000_000Tb4b4b4,
Ta5d6ff"Ta5d6ffsender_hashTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffsender_hashTa5d6ff"Tb4b4b4]Tff7b72.Td2a8ffhexTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffsourceTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffsourceTa5d6ff"Tb4b4b4]Tb4b4b4,
Tb4b4b4} Tff7b72for Te6edf3r Tff7b72in Te6edf3rowsTb4b4b4]Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6fferrorTa5d6ff"Tb4b4b4: Tffa657strTb4b4b4(Te6edf3eTb4b4b4)Tb4b4b4}Tb4b4b4)Tb4b4b4, T79c0ff500
Te6edf3HALOW_URL Tff7b72= Ta5d6ff"Ta5d6ffhttps://rnode-halow.ru/nodes_status.jsonTa5d6ff"
Tff7b72def Td2a8ff_fetch_halow_nodesTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3req Tff7b72= Te6edf3urllibTff7b72.Td2a8ffrequestTff7b72.Td2a8ffRequestTb4b4b4(Te6edf3HALOW_URLTb4b4b4, Te6edf3headersTff7b72=Tb4b4b4{Ta5d6ff"Ta5d6ffUser-AgentTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffwardrive-map/1.0Ta5d6ff"Tb4b4b4}Tb4b4b4)
Tff7b72with Te6edf3urllibTff7b72.Td2a8ffrequestTff7b72.Td2a8ffurlopenTb4b4b4(Te6edf3reqTb4b4b4, Te6edf3timeoutTff7b72=T79c0ff10Tb4b4b4) Tff7b72as Te6edf3respTb4b4b4:
Te6edf3data Tff7b72= Te6edf3jsonTff7b72.Td2a8ffloadsTb4b4b4(Te6edf3respTff7b72.Td2a8ffreadTb4b4b4(Tb4b4b4)Tb4b4b4)
Te6edf3nodes Tff7b72= Tb4b4b4[Tb4b4b4]
Tff7b72for Te6edf3d Tff7b72in Te6edf3dataTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffnodesTa5d6ff"Tb4b4b4, Tb4b4b4[Tb4b4b4]Tb4b4b4)Tb4b4b4:
Te6edf3lat Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4)
Te6edf3lon Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4)
Tff7b72if Te6edf3lat Tff7b72is Tff7b72None Tff7b72or Te6edf3lon Tff7b72is Tff7b72NoneTb4b4b4:
Tff7b72continue
Te6edf3node Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffHaLowTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6fftype_rawTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffHaLowInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4: Te6edf3latTb4b4b4,
Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4: Te6edf3lonTb4b4b4,
Ta5d6ff"Ta5d6fflast_heardTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fftsTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fffreq_strTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fffreqTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffbw_strTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffbwTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fffwTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fffwTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffuptimeTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffuptimeTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fflxmfTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflxmfTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffmcsTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffmcsTa5d6ff"Tb4b4b4)Tb4b4b4,
Tb4b4b4}
Tff7b72if Ta5d6ff"Ta5d6fftx_pTa5d6ff" Tff7b72in Te6edf3dTb4b4b4:
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6fftx_pTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTb4b4b4[Ta5d6ff"Ta5d6fftx_pTa5d6ff"Tb4b4b4]
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffrx_pTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffrx_pTa5d6ff"Tb4b4b4, T79c0ff0Tb4b4b4)
Te6edf3nodesTff7b72.Td2a8ffappendTb4b4b4(Te6edf3nodeTb4b4b4)
Tff7b72return Te6edf3nodes
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[WARN] HaLow fetch failed: Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Tff7b72return Tb4b4b4[Tb4b4b4]
Te6edf3TYPE_LABELS Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffRNodeInterfaceTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffRNodeTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffBackboneInterfaceTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffBackboneTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffTCPServerInterfaceTa5d6ff"Tb4b4b4:Ta5d6ff"Ta5d6ffTCPTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffTCPClientInterfaceTa5d6ff"Tb4b4b4:Ta5d6ff"Ta5d6ffTCPTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffI2PInterfaceTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffI2PTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffUDPInterfaceTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffUDPTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffAutoInterfaceTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffAutoTa5d6ff"Tb4b4b4,
Tb4b4b4}
Tff7b72def Td2a8ff_fetch_rnstatus_nodesTb4b4b4(Tb4b4b4)Tb4b4b4:
T8b949e"""Query rnstatus for discovered interfaces. Raises on failure/timeout."""
Te6edf3nodes Tff7b72= Tb4b4b4[Tb4b4b4]
Te6edf3result Tff7b72= Te6edf3subprocessTff7b72.Td2a8ffrunTb4b4b4(
Tb4b4b4[Ta5d6ff"Ta5d6ffrnstatusTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff-dTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff-jTa5d6ff"Tb4b4b4]Tb4b4b4,
Te6edf3capture_outputTff7b72=Tff7b72TrueTb4b4b4, Te6edf3textTff7b72=Tff7b72TrueTb4b4b4, Te6edf3timeoutTff7b72=T79c0ff10
Tb4b4b4)
Te6edf3raw Tff7b72= Te6edf3jsonTff7b72.Td2a8ffloadsTb4b4b4(Te6edf3resultTff7b72.Td2a8ffstdoutTb4b4b4)
Te6edf3cutoff Tff7b72= Te6edf3timeTff7b72.Td2a8fftimeTb4b4b4(Tb4b4b4) Tff7b72- Te6edf3NODE_PRUNE_AGE
Tff7b72for Te6edf3d Tff7b72in Te6edf3rawTb4b4b4:
Te6edf3rtype Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)
Tff7b72if Te6edf3rtype Tff7b72not Tff7b72in Tb4b4b4(Ta5d6ff"Ta5d6ffRNodeInterfaceTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffBackboneInterfaceTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffI2PInterfaceTa5d6ff"Tb4b4b4)Tb4b4b4:
Tff7b72continue
Te6edf3lat Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflatitudeTa5d6ff"Tb4b4b4)
Te6edf3lon Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflongitudeTa5d6ff"Tb4b4b4)
Tff7b72if Te6edf3lat Tff7b72is Tff7b72None Tff7b72or Te6edf3lon Tff7b72is Tff7b72NoneTb4b4b4:
Tff7b72continue
Te6edf3last_heard Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflast_heardTa5d6ff"Tb4b4b4)
Tff7b72if Te6edf3last_heard Tff7b72is Tff7b72not Tff7b72None Tff7b72and Te6edf3last_heard Tff7b72< Te6edf3cutoffTb4b4b4:
Tff7b72continue
Te6edf3node Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Te6edf3TYPE_LABELSTff7b72.Td2a8ffgetTb4b4b4(Te6edf3rtypeTb4b4b4, Te6edf3rtypeTb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fftype_rawTa5d6ff"Tb4b4b4: Te6edf3rtypeTb4b4b4,
Ta5d6ff"Ta5d6ffhopsTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffhopsTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffvalueTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffvalueTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fflast_heardTa5d6ff"Tb4b4b4: Te6edf3last_heardTb4b4b4,
Ta5d6ff"Ta5d6ffheard_countTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffheard_countTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffdiscoveredTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdiscoveredTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffstatusTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffstatusTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4: Te6edf3latTb4b4b4,
Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4: Te6edf3lonTb4b4b4,
Ta5d6ff"Ta5d6ffheightTa5d6ff"Tb4b4b4: Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffheightTa5d6ff"Tb4b4b4)Tb4b4b4,
Tb4b4b4}
Tff7b72if Te6edf3rtype Tff7b72== Ta5d6ff"Ta5d6ffRNodeInterfaceTa5d6ff"Tb4b4b4:
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6fffrequencyTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fffrequencyTa5d6ff"Tb4b4b4)
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffbandwidthTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffbandwidthTa5d6ff"Tb4b4b4)
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffsfTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffsfTa5d6ff"Tb4b4b4)
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffcrTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffcrTa5d6ff"Tb4b4b4)
Tff7b72elif Te6edf3rtype Tff7b72== Ta5d6ff"Ta5d6ffBackboneInterfaceTa5d6ff"Tb4b4b4:
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffremoteTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffreachable_onTa5d6ff"Tb4b4b4)
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffportTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffportTa5d6ff"Tb4b4b4)
Tff7b72elif Te6edf3rtype Tff7b72== Ta5d6ff"Ta5d6ffI2PInterfaceTa5d6ff"Tb4b4b4:
Te6edf3nodeTb4b4b4[Ta5d6ff"Ta5d6ffremoteTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3dTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffreachable_onTa5d6ff"Tb4b4b4)
Te6edf3nodesTff7b72.Td2a8ffappendTb4b4b4(Te6edf3nodeTb4b4b4)
Tff7b72return Te6edf3nodes
T8b949e# Background discovery cache. rnstatus (subprocess) and HaLow (HTTP) are both
T8b949e# blocking calls; running them per-request means a wedged rnsd or slow upstream
T8b949e# stalls every page load and drops all RNodes. Instead a daemon thread refreshes
T8b949e# these on an interval and requests serve the last known-good snapshot.
Te6edf3_disc_lock Tff7b72= Te6edf3threadingTff7b72.Td2a8ffLockTb4b4b4(Tb4b4b4)
Te6edf3_disc_cache Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffrnstatusTa5d6ff"Tb4b4b4: Tb4b4b4[Tb4b4b4]Tb4b4b4, T8b949e# last successful rnstatus node list
Ta5d6ff"Ta5d6ffrnstatus_tsTa5d6ff"Tb4b4b4: T79c0ff0.0Tb4b4b4, T8b949e# when that list was fetched (0 = never)
Ta5d6ff"Ta5d6ffhalowTa5d6ff"Tb4b4b4: Tb4b4b4[Tb4b4b4]Tb4b4b4,
Tb4b4b4}
T8b949e# rnstatus data older than this is considered unavailable (signals the UI).
Te6edf3DISCOVERY_STALE_SECS Tff7b72= Tffa657maxTb4b4b4(T79c0ff60Tb4b4b4, Te6edf3DISCOVERY_REFRESH_SECS Tff7b72* T79c0ff4Tb4b4b4)
Tff7b72def Td2a8ff_refresh_discoveryTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72while Tff7b72TrueTb4b4b4:
Tff7b72tryTb4b4b4:
Te6edf3nodes Tff7b72= Te6edf3_fetch_rnstatus_nodesTb4b4b4(Tb4b4b4)
Tff7b72with Te6edf3_disc_lockTb4b4b4:
Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffrnstatusTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3nodes
Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffrnstatus_tsTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3timeTff7b72.Td2a8fftimeTb4b4b4(Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
T8b949e# Keep serving the last good snapshot; api_discovered flags staleness.
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[WARN] rnstatus refresh failed (serving cached data): Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Tff7b72tryTb4b4b4:
Te6edf3halow Tff7b72= Te6edf3_fetch_halow_nodesTb4b4b4(Tb4b4b4)
Tff7b72with Te6edf3_disc_lockTb4b4b4:
Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffhalowTa5d6ff"Tb4b4b4] Tff7b72= Te6edf3halow
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[WARN] HaLow refresh failed (serving cached data): Tffd700{Te6edf3eTffd700}Ta5d6ff"Tb4b4b4, Te6edf3flushTff7b72=Tff7b72TrueTb4b4b4)
Te6edf3timeTff7b72.Td2a8ffsleepTb4b4b4(Te6edf3DISCOVERY_REFRESH_SECSTb4b4b4)
Te6edf3threadingTff7b72.Td2a8ffThreadTb4b4b4(Te6edf3targetTff7b72=Te6edf3_refresh_discoveryTb4b4b4, Te6edf3daemonTff7b72=Tff7b72TrueTb4b4b4)Tff7b72.Td2a8ffstartTb4b4b4(Tb4b4b4)
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/discoveredTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_discoveredTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72with Te6edf3_disc_lockTb4b4b4:
Te6edf3nodes Tff7b72= Tb4b4b4[Tffa657dictTb4b4b4(Te6edf3nTb4b4b4) Tff7b72for Te6edf3n Tff7b72in Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffrnstatusTa5d6ff"Tb4b4b4]Tb4b4b4]
Te6edf3halow Tff7b72= Tb4b4b4[Tffa657dictTb4b4b4(Te6edf3nTb4b4b4) Tff7b72for Te6edf3n Tff7b72in Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffhalowTa5d6ff"Tb4b4b4]Tb4b4b4]
Te6edf3rnstatus_ts Tff7b72= Te6edf3_disc_cacheTb4b4b4[Ta5d6ff"Ta5d6ffrnstatus_tsTa5d6ff"Tb4b4b4]
Te6edf3rnstatus_ok Tff7b72= Te6edf3rnstatus_ts Tff7b72> T79c0ff0 Tff7b72and Tb4b4b4(Te6edf3timeTff7b72.Td2a8fftimeTb4b4b4(Tb4b4b4) Tff7b72- Te6edf3rnstatus_tsTb4b4b4) Tff7b72< Te6edf3DISCOVERY_STALE_SECS
Tff7b72for Te6edf3p Tff7b72in Te6edf3PINNED_NODESTb4b4b4:
Tff7b72if Te6edf3pTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4) Tff7b72is Tff7b72None Tff7b72or Te6edf3pTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4) Tff7b72is Tff7b72NoneTb4b4b4:
Tff7b72continue
Te6edf3node Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Te6edf3pTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffPinned NodeTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Te6edf3pTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffRNodeTa5d6ff"Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4: Te6edf3pTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4: Te6edf3pTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6ffpinnedTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Tb4b4b4}
Tff7b72for Te6edf3k Tff7b72in Tb4b4b4(Ta5d6ff"Ta5d6fffrequencyTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffbandwidthTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffsfTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffcrTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffheightTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffremoteTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffportTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffhashTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffb32Ta5d6ff"Tb4b4b4)Tb4b4b4:
Tff7b72if Te6edf3k Tff7b72in Te6edf3pTb4b4b4:
Te6edf3nodeTb4b4b4[Te6edf3kTb4b4b4] Tff7b72= Te6edf3pTb4b4b4[Te6edf3kTb4b4b4]
Te6edf3nodesTff7b72.Td2a8ffappendTb4b4b4(Te6edf3nodeTb4b4b4)
Te6edf3nodesTff7b72.Td2a8ffextendTb4b4b4(Te6edf3halowTb4b4b4)
Te6edf3_enrich_geoTb4b4b4(Te6edf3nodesTb4b4b4)
Te6edf3resp Tff7b72= Te6edf3jsonifyTb4b4b4(Te6edf3nodesTb4b4b4)
Te6edf3respTff7b72.Td2a8ffheadersTb4b4b4[Ta5d6ff"Ta5d6ffX-Rnstatus-OKTa5d6ff"Tb4b4b4] Tff7b72= Ta5d6ff"Ta5d6ff1Ta5d6ff" Tff7b72if Te6edf3rnstatus_ok Tff7b72else Ta5d6ff"Ta5d6ff0Ta5d6ff"
Tff7b72return Te6edf3resp
Te6edf3FAVICON_PATH Tff7b72= Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffjoinTb4b4b4(Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffdirnameTb4b4b4(Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffabspathTb4b4b4(Tff7b72__file__Tb4b4b4)Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6fffavicon.icoTa5d6ff"Tb4b4b4)
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/favicon.icoTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8fffaviconTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72return Te6edf3send_fileTb4b4b4(Te6edf3FAVICON_PATHTb4b4b4, Te6edf3mimetypeTff7b72=Ta5d6ff"Ta5d6ffimage/vnd.microsoft.iconTa5d6ff"Tb4b4b4)
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/geojsonTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_geojsonTb4b4b4(Tb4b4b4)Tb4b4b4:
Te6edf3since Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffsinceTa5d6ff"Tb4b4b4, T79c0ff0Tb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Te6edf3from_ts Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6fffrom_tsTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Te6edf3to_ts Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffto_tsTa5d6ff"Tb4b4b4, Tff7b72NoneTb4b4b4, Tffa657typeTff7b72=Tffa657intTb4b4b4)
Te6edf3direction Tff7b72= Te6edf3requestTff7b72.Td2a8ffargsTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdirectionTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffupTa5d6ff"Tb4b4b4) T8b949e# "up" or "down"
Te6edf3dl Tff7b72= Tb4b4b4(Te6edf3direction Tff7b72== Ta5d6ff"Ta5d6ffdownTa5d6ff"Tb4b4b4)
Tff7b72tryTb4b4b4:
Te6edf3con Tff7b72= Te6edf3get_dbTb4b4b4(Tb4b4b4)
Te6edf3rssi_col Tff7b72= Ta5d6ff"Ta5d6ffdl_rssi AS rssi, dl_snr AS snrTa5d6ff" Tff7b72if Te6edf3dl Tff7b72else Ta5d6ff"Ta5d6ffrssi, snrTa5d6ff"
T8b949e# 'relay' points carry downlink stats too (a site bot's own probe reply, forwarded
T8b949e# to the aggregator) — that's the normal case, since a phone talks to its local site
T8b949e# bot, not the aggregator directly. Restricting to source='probe' here excluded
T8b949e# basically all real-world downlink data.
Te6edf3dl_filter Tff7b72= Ta5d6ff"Ta5d6ff AND source IN (Ta5d6ff'Ta5d6ffprobeTa5d6ff'Ta5d6ff, Ta5d6ff'Ta5d6ffrelayTa5d6ff'Ta5d6ff) AND dl_rssi IS NOT NULLTa5d6ff" Tff7b72if Te6edf3dl Tff7b72else Ta5d6ff"Ta5d6ff"
Te6edf3base Tff7b72= Ta5d6fffTa5d6ff"Ta5d6ffSELECT lat, lon, Tffd700{Te6edf3rssi_colTffd700}Ta5d6ff, q, ts, source FROM pointsTa5d6ff"
Tff7b72if Te6edf3from_ts Tff7b72is Tff7b72not Tff7b72NoneTb4b4b4:
Te6edf3rows Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(
Ta5d6fffTa5d6ff"Tffd700{Te6edf3baseTffd700}Ta5d6ff WHERE ts >= ? AND ts <= ?Tffd700{Te6edf3dl_filterTffd700}Ta5d6ff ORDER BY tsTa5d6ff"Tb4b4b4,
Tb4b4b4(Te6edf3from_tsTb4b4b4, Te6edf3to_ts Tff7b72if Te6edf3to_ts Tff7b72is Tff7b72not Tff7b72None Tff7b72else Tffa657intTb4b4b4(Te6edf3timeTff7b72.Td2a8fftimeTb4b4b4(Tb4b4b4)Tb4b4b4)Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tff7b72.Td2a8fffetchallTb4b4b4(Tb4b4b4)
Tff7b72elseTb4b4b4:
Te6edf3rows Tff7b72= Te6edf3conTff7b72.Td2a8ffexecuteTb4b4b4(
Ta5d6fffTa5d6ff"Tffd700{Te6edf3baseTffd700}Ta5d6ff WHERE ts > ?Tffd700{Te6edf3dl_filterTffd700}Ta5d6ff ORDER BY tsTa5d6ff"Tb4b4b4,
Tb4b4b4(Te6edf3sinceTb4b4b4,Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tff7b72.Td2a8fffetchallTb4b4b4(Tb4b4b4)
Te6edf3conTff7b72.Td2a8ffcloseTb4b4b4(Tb4b4b4)
Te6edf3features Tff7b72= Tb4b4b4[
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffFeatureTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffgeometryTa5d6ff"Tb4b4b4: Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffPointTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffcoordinatesTa5d6ff"Tb4b4b4: Tb4b4b4[Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6fflonTa5d6ff"Tb4b4b4] Tff7b72/ T79c0ff1_000_000Tb4b4b4, Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6fflatTa5d6ff"Tb4b4b4] Tff7b72/ T79c0ff1_000_000Tb4b4b4]Tb4b4b4,
Tb4b4b4}Tb4b4b4,
Ta5d6ff"Ta5d6ffpropertiesTa5d6ff"Tb4b4b4: Tb4b4b4{
Ta5d6ff"Ta5d6ffrssiTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffrssiTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6ffsnrTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffsnrTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6ffqTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffqTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6fftsTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6fftsTa5d6ff"Tb4b4b4]Tb4b4b4,
Ta5d6ff"Ta5d6ffsourceTa5d6ff"Tb4b4b4: Te6edf3rTb4b4b4[Ta5d6ff"Ta5d6ffsourceTa5d6ff"Tb4b4b4]Tb4b4b4,
Tb4b4b4}Tb4b4b4,
Tb4b4b4}
Tff7b72for Te6edf3r Tff7b72in Te6edf3rows
Tb4b4b4]
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffFeatureCollectionTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6fffeaturesTa5d6ff"Tb4b4b4: Te6edf3featuresTb4b4b4}Tb4b4b4)
Tff7b72except Tf85149Exception Tff7b72as Te6edf3eTb4b4b4:
Tff7b72return Te6edf3jsonifyTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6fferrorTa5d6ff"Tb4b4b4: Tffa657strTb4b4b4(Te6edf3eTb4b4b4)Tb4b4b4}Tb4b4b4)Tb4b4b4, T79c0ff500
Tf0883e@appTff7b72.Td2a8ffrouteTb4b4b4(Ta5d6ff"Ta5d6ff/api/geoTa5d6ff"Tb4b4b4)
Tff7b72def Td2a8ffapi_geoTb4b4b4(Tb4b4b4)Tb4b4b4:
Tff7b72with Te6edf3_geo_lockTb4b4b4:
Tff7b72return Te6edf3jsonifyTb4b4b4(Tffa657dictTb4b4b4(Te6edf3_geo_cacheTb4b4b4)Tb4b4b4)
Tff7b72if Tff7b72__name__ Tff7b72== Ta5d6ff"Ta5d6ff__main__Ta5d6ff"Tb4b4b4:
Tff7b72if Tff7b72not Te6edf3osTff7b72.Td2a8ffpathTff7b72.Td2a8ffexistsTb4b4b4(Te6edf3DB_PATHTb4b4b4)Tb4b4b4:
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ff[WARN] Database not found at Tffd700{Te6edf3DB_PATHTffd700}Ta5d6ff — map will be empty until wardrive_bot writes dataTa5d6ff"Tb4b4b4)
Tffa657printTb4b4b4(Ta5d6fffTa5d6ff"Ta5d6ffMap server starting on http://Tffd700{Te6edf3HOSTTffd700}Ta5d6ff:Tffd700{Te6edf3PORTTffd700}Ta5d6ff"Tb4b4b4)
Te6edf3appTff7b72.Td2a8ffrunTb4b4b4(Te6edf3hostTff7b72=Te6edf3HOSTTb4b4b4, Te6edf3portTff7b72=Te6edf3PORTTb4b4b4, Te6edf3debugTff7b72=Tff7b72FalseTb4b4b4)
Served by rngit 1.4.2 - Generated in 0.17s